diff --git a/.gitea/workflows/server-deploy.yml b/.gitea/workflows/server-deploy.yml index 6f27bd8..c3dc78d 100644 --- a/.gitea/workflows/server-deploy.yml +++ b/.gitea/workflows/server-deploy.yml @@ -24,7 +24,8 @@ jobs: - name: Build and push server image run: | IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest" - docker build -t "$IMAGE" -f server/Dockerfile server/ + # Root context: server depends on the shared module. + docker build -t "$IMAGE" -f server/Dockerfile . docker push "$IMAGE" - name: Build and push web image @@ -49,5 +50,6 @@ jobs: - name: Build and push sitesvc image run: | IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest" - docker build -t "$IMAGE" -f sitesvc/Dockerfile sitesvc/ + # Root context: sitesvc depends on the shared module. + docker build -t "$IMAGE" -f sitesvc/Dockerfile . docker push "$IMAGE" diff --git a/.gitignore b/.gitignore index 5519071..d9c7a05 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ node_modules dist build .env -docs +docs/* +!docs/superpowers/ .superpowers installer/vantage-agent-windows-amd64.exe installer/*.msi diff --git a/agent/internal/inventory/collect_other.go b/agent/internal/inventory/collect_other.go index 59c3c1b..8a0921f 100644 --- a/agent/internal/inventory/collect_other.go +++ b/agent/internal/inventory/collect_other.go @@ -1,5 +1,9 @@ +//go:build !linux - +// Inventory collection is Linux-only. This no-op stands in everywhere else. +// +// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so +// without it this file compiles on Linux too and collides with collect_linux.go. package inventory import "github.com/mrhid6/vantage/agent/internal/grpc/pb" diff --git a/docs/superpowers/plans/2026-07-24-instance-rename.md b/docs/superpowers/plans/2026-07-24-instance-rename.md new file mode 100644 index 0000000..bd32e0c --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-instance-rename.md @@ -0,0 +1,1440 @@ +# Org to Instance Rename Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rename the tenant entity from `Org` to `Instance` everywhere — Go types, collections, the `org_id` field on every document, REST routes and UI copy — with a migration that only ever renames and never deletes. + +**Architecture:** A code rename driven from the `shared` module outward, plus migration `0004_org_to_instance` that renames two collections and `$rename`s one field across every scoped collection. The migration verifies document counts before recording its marker, so a partial run retries rather than half-completing. sitesvc refuses to boot against an unmigrated database. + +**Tech Stack:** Go 1.26, MongoDB driver v2, Next.js 16, Docker. + +**No automated tests.** This moves the tenant isolation key across 17 collections. With no test suite, the production-snapshot rehearsal in Task 8 is not optional and not a formality — it is the only evidence that no tenant's data was orphaned. Do not deploy without completing every step of it, including the rollback rehearsal. + +## Global Constraints + +- **Depends on plan 0a being merged and deployed.** Do not start otherwise; this plan assumes one definition of `Org`, `User` and `Settings` in `shared`. +- Take a database backup before deploying. The inverse rename is the first recovery path; the backup is the second. +- **The migration renames. It never deletes, drops or unsets** — the one exception is dropping indexes keyed on the old field name, which touches no documents. +- No behaviour changes. Same permissions, same responses, same data. +- The `agent` module and `agent-release.yml` are not touched by any task. +- REST route renames are breaking and ship in the same release as the frontend. No compatibility aliases. +- After Task 4, `grep -rn '"org_id"' shared/ server/ sitesvc/` must return hits **only** in `migrate_instance.go`. + +## The naming map + +Apply exactly. Every task references this table. + +| Today | After | +|---|---| +| collection `orgs` | `instances` | +| collection `org_oidc` | `instance_oidc` | +| field `org_id` | `instance_id` | +| `models.Org` | `models.Instance` | +| `Org.OrgID` | `Instance.InstanceID` | +| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` | +| `provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` | +| `services/orgs.go` | `services/instances.go` | +| `GetOrg`, `GetOrgBySlug`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg` | `GetInstance`, `GetInstanceBySlug`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance` | +| `CountOrgUsers`, `GetUserInOrg` | `CountInstanceUsers`, `GetUserInInstance` | +| `services/org_oidc.go` | `services/instance_oidc.go` | +| `auth/orghost.go` | `auth/instancehost.go` | +| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` | +| session field `org_id` | `instance_id` | +| `GET /auth/me` fields `org_id`, `org` | `instance_id`, `instance` | +| `PendingSignup.OrgName` / `bson:"org_name"` | `InstanceName` / `bson:"instance_name"` | +| UI copy "Organisation"/"Organization" (tenant) | "Instance" | +| UI copy "Organisation" (customer, marketing site) | "Account" | + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `shared/models/instance.go` | `Instance` (replaces `org.go`) | +| `shared/provision/instance.go` | `CreateInstance`, `RollbackInstance` (replaces `org.go`) | +| `server/internal/services/instances.go` | replaces `orgs.go` | +| `server/internal/services/instance_oidc.go` | replaces `org_oidc.go` | +| `server/internal/auth/instancehost.go` | replaces `orghost.go` | +| `server/internal/services/migrate_instance.go` | migration `0004`, `ScopedCollections`, boot assertion | +| `server/cmd/rename-rollback/main.go` | one-shot inverse rename | + +**Modified:** `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go`, every file in `server/internal/` referencing a renamed symbol, `server/cmd/main.go`, `sitesvc/internal/{models,store,api}`, `sitesvc/cmd/main.go`, `web/` and `site/` sources. + +--- + +### Task 1: Rename in the shared module + +Everything downstream depends on these names, so they change first. + +**Files:** +- Rename: `shared/models/org.go` → `shared/models/instance.go` +- Rename: `shared/provision/org.go` → `shared/provision/instance.go` +- Modify: `shared/models/user.go`, `shared/models/settings.go`, `shared/provision/user.go`, `shared/indexes/indexes.go` + +**Interfaces:** +- Consumes: the plan 0a API +- Produces: + - `models.Instance` with field `InstanceID string` and tag `bson:"instance_id"` + - `models.User.InstanceID`, `models.Settings.InstanceID` + - `provision.CreateInstance(ctx, db, name) (*models.Instance, error)` + - `provision.RollbackInstance(ctx, db, instanceID) error` + - `provision.CreateUser(ctx, db, instanceID, email, password, role, authSource)` — first argument renamed, signature otherwise unchanged + - `provision.CreateUserWithHash(ctx, db, instanceID, email, passwordHash, role, authSource)` + - `indexes.EnsureCoreIndexes` — now indexes `instances.slug` + +- [ ] **Step 1: Rename the Instance model** + +```bash +cd c:/Work/Repos/vantage +git mv shared/models/org.go shared/models/instance.go +``` + +Replace its contents with: + +```go +// Package models holds the MongoDB documents written by more than one Vantage +// service. +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Instance is one deployment of Vantage: its own subdomain, users, servers, +// keys, workflows, monitors and secrets. It is the unit a licence attaches to. +// +// A paying customer may hold several. That grouping is called an Account and +// lives only in the admin control plane — this service never sees it. +type Instance struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} +``` + +- [ ] **Step 2: Rename the fields on User and Settings** + +In `shared/models/user.go`, change the one field: + +```go + InstanceID string `bson:"instance_id" json:"instance_id"` +``` + +In `shared/models/settings.go`, change the one field: + +```go + InstanceID string `bson:"instance_id" json:"instance_id"` +``` + +- [ ] **Step 3: Rename the provisioning functions** + +```bash +cd c:/Work/Repos/vantage +git mv shared/provision/org.go shared/provision/instance.go +``` + +Replace its contents with: + +```go +package provision + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ErrNameRejected wraps every reason a name cannot become an instance. +var ErrNameRejected = errors.New("organisation name rejected") + +const maxSlugAttempts = 50 + +// CreateInstance inserts an instance under the first free slug derived from name. +// +// The count-then-insert loop is racy on its own. It is safe only because +// instances.slug carries a unique index: a lost race surfaces as a duplicate-key +// error, which we treat as "that slug is taken" and retry. Do not remove the +// duplicate-key branch, and do not remove the index. +func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { + base, err := BaseSlug(name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) + } + + for attempt := 1; attempt <= maxSlugAttempts; attempt++ { + slug := NextSlug(base, attempt) + + n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n > 0 { + continue + } + + inst := models.Instance{ + InstanceID: uuid.NewString(), + Name: name, + Slug: slug, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil { + if mongo.IsDuplicateKeyError(err) { + continue // lost the race; try the next slug + } + return nil, err + } + return &inst, nil + } + return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) +} + +// RollbackInstance deletes an instance that has no users. +// +// It refuses an instance that has users. Rollback exists to clean up a +// half-finished signup, and an instance with users is not half-finished. +func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error { + n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if n > 0 { + return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n) + } + _, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID}) + return err +} +``` + +The `ErrNameRejected` text keeps saying "organisation name" — that message is about the *customer's* organisation name as typed into a form, which is still the right word. Task 7 revisits UI copy; this is not it. + +- [ ] **Step 4: Rename the user provisioning parameter** + +In `shared/provision/user.go`, change both signatures and the struct literal: + +```go +func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) { + var hash string + if password != "" { + b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return nil, err + } + hash = string(b) + } + return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource) +} + +func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + if !models.ValidRole(role) { + return nil, fmt.Errorf("invalid role %q", role) + } + + u := &models.User{ + UserID: uuid.NewString(), + InstanceID: instanceID, + Email: email, + PasswordHash: passwordHash, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, ErrEmailTaken + } + return nil, err + } + return u, nil +} +``` + +- [ ] **Step 5: Point the index at the renamed collection** + +In `shared/indexes/indexes.go`, change the second index block: + +```go + if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("instances.slug index: %w", err) + } +``` + +- [ ] **Step 6: Build and confirm no stale references** + +```bash +cd c:/Work/Repos/vantage/shared +go build ./... && go vet ./... +cd .. +grep -rn "org_id\|OrgID\|\"orgs\"\|CreateOrg\|RollbackOrg" shared/ +``` + +Expected: no output from any of them. + +- [ ] **Step 7: Commit** + +```bash +git add shared/ +git commit -m "refactor(shared): rename Org to Instance" +``` + +--- + +### Task 2: The scoped-collection list and the boot assertion + +A code constant, not a runbook list. A collection missing from it is a collection whose tenant key never gets renamed — and with no test suite, the boot assertion is what catches that. + +**Files:** +- Create: `server/internal/services/migrate_instance.go` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `var ScopedCollections []string` + - `func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error` + +- [ ] **Step 1: Write the list and the assertion** + +Create `server/internal/services/migrate_instance.go`: + +```go +package services + +import ( + "context" + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ScopedCollections lists every collection carrying the tenant key. +// +// Migration 0004 renames org_id to instance_id in each. A collection missing +// from this list keeps the old field name and becomes invisible to every scoped +// query — so this list is load-bearing, not documentation. +// +// AssertNoScopedCollectionMissed checks at boot that nothing outside this list +// holds an org_id. +// +// The migrations collection is deliberately absent: it is not tenant-scoped. +// The two renamed collections appear under their post-rename names, because the +// migration renames the collections before it renames the field. +var ScopedCollections = []string{ + "instances", + "servers", + "keys", + "assignments", + "users", + "instance_oidc", + "settings", + "secrets", + "workflows", + "workflow_steps", + "workflow_runs", + "monitors", + "incidents", + "monitor_rollups", + "notification_channels", + "console_sessions", + "audit_logs", +} + +// AssertNoScopedCollectionMissed reports any collection holding an org_id that +// ScopedCollections does not know about. A hit means a collection was added +// without being added to the list, and its tenant key was never renamed. +func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error { + known := map[string]bool{} + for _, c := range ScopedCollections { + known[c] = true + } + + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + for _, n := range names { + if known[n] { + continue + } + count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count %s: %w", n, err) + } + if count > 0 { + return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count) + } + } + return nil +} +``` + +- [ ] **Step 2: Cross-check the list against the documented collections** + +```bash +cd c:/Work/Repos/vantage +grep -n "console_sessions\|audit_logs\|monitor_rollups\|notification_channels" claude.md +``` + +Expected: the collection list in `claude.md` matches `ScopedCollections`, allowing for `orgs`→`instances` and `org_oidc`→`instance_oidc`. Any collection in the docs but not in the list must be added, and vice versa. `migrations` is the only intentional omission. + +- [ ] **Step 3: Cross-check against a live database** + +Against a restored production snapshot, in `mongosh`: + +```javascript +use vantage_snapshot +db.getCollectionNames().sort().forEach(function (c) { + var n = db.getCollection(c).countDocuments({org_id: {$exists: true}}); + if (n > 0) print(c + " " + n); +}) +``` + +Expected: exactly the 17 collections in `ScopedCollections`, with `orgs` and `org_oidc` in place of their renamed forms. **Anything else in that output must be added to the list before continuing.** + +- [ ] **Step 4: Build and commit** + +```bash +cd c:/Work/Repos/vantage/server && go build ./... +cd .. +git add server/internal/services/migrate_instance.go +git commit -m "chore(server): add ScopedCollections and the boot assertion" +``` + +--- + +### Task 3: Migration 0004 + +The risky part of the plan. + +**Files:** +- Modify: `server/internal/services/migrate_instance.go` + +**Interfaces:** +- Consumes: `ScopedCollections` +- Produces: `func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error` + +Takes an explicit `*mongo.Database` rather than using the package-level `db.Database`, so it can be pointed at a snapshot database during rehearsal. `server/cmd/main.go` passes `db.Database`. + +- [ ] **Step 1: Write the migration** + +Append to `server/internal/services/migrate_instance.go`: + +```go +// collectionRenames maps the two collections whose names change. Ordered so the +// migration is deterministic. +var collectionRenames = []struct{ from, to string }{ + {"orgs", "instances"}, + {"org_oidc", "instance_oidc"}, +} + +// MigrateOrgToInstance renames the tenant key from org_id to instance_id. +// +// It only ever renames documents. It never deletes, drops or unsets one, so a +// bad deploy is recovered by running the inverse rename (cmd/rename-rollback) +// rather than by restoring a backup. +// +// The steps are not atomic across collections — multi-document transactions +// would require a replica set, which self-hosted installs do not guarantee. +// Instead every step is safely repeatable: a collection rename is skipped when +// the source is already gone, and $rename matches nothing on a document that +// has already been renamed. A run that fails partway is fixed by running it +// again. +func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error { + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + exists := map[string]bool{} + for _, n := range names { + exists[n] = true + } + + // Step 1: rename the collections. + for _, r := range collectionRenames { + switch { + case !exists[r.from]: + // Nothing to rename: either already done or never existed. + continue + case exists[r.to]: + return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to) + } + cmd := bson.D{ + {Key: "renameCollection", Value: db.Name() + "." + r.from}, + {Key: "to", Value: db.Name() + "." + r.to}, + } + if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil { + return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err) + } + log.Printf("0004: renamed collection %s to %s", r.from, r.to) + } + + // Step 2: rename the field. + for _, c := range ScopedCollections { + res, err := db.Collection(c).UpdateMany(ctx, + bson.M{"org_id": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"org_id": "instance_id"}}, + ) + if err != nil { + return fmt.Errorf("rename org_id in %s: %w", c, err) + } + if res.ModifiedCount > 0 { + log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount) + } + } + + // Step 3: verify before anyone records a marker. Any mismatch aborts, and + // the migration is re-run rather than marked done. + for _, c := range ScopedCollections { + total, err := db.Collection(c).CountDocuments(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("count %s: %w", c, err) + } + if total == 0 { + continue + } + + stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count stale in %s: %w", c, err) + } + if stale != 0 { + return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale) + } + + scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count scoped in %s: %w", c, err) + } + if scoped != total { + return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped) + } + } + + // Step 4: indexes keyed on the old field name now point at a field that no + // longer exists. Drop them; the boot-time index builders recreate the + // current ones. Dropping an index touches no documents. + for _, c := range ScopedCollections { + cur, err := db.Collection(c).Indexes().List(ctx) + if err != nil { + return fmt.Errorf("list indexes on %s: %w", c, err) + } + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + return fmt.Errorf("decode indexes on %s: %w", c, err) + } + for _, s := range specs { + name, _ := s["name"].(string) + if name == "_id_" { + continue + } + keys, ok := s["key"].(bson.M) + if !ok { + continue + } + if _, keyed := keys["org_id"]; !keyed { + continue + } + if _, err := db.Collection(c).Indexes().DropOne(ctx, name); err != nil { + return fmt.Errorf("drop index %s on %s: %w", name, c, err) + } + log.Printf("0004: dropped stale index %s on %s", name, c) + } + } + + log.Printf("0004: verified %d collection(s)", len(ScopedCollections)) + return nil +} +``` + +Add `"log"` to the import block. + +- [ ] **Step 2: Build** + +Run: `cd server && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 3: Dry-run against a disposable copy** + +Do not wait for Task 8 to find out whether this works. Make a scratch copy now: + +```bash +mongodump --uri mongodb://localhost:27017 --db vantage --out /tmp/dump +mongorestore --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dump/vantage +``` + +Write a throwaway runner at `server/cmd/migratecheck/main.go`: + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/services" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + c, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017")) + if err != nil { + log.Fatal(err) + } + db := c.Database("vantage_dryrun") + + if err := services.MigrateOrgToInstance(ctx, db); err != nil { + log.Fatalf("migration: %v", err) + } + if err := services.AssertNoScopedCollectionMissed(ctx, db); err != nil { + log.Fatalf("assertion: %v", err) + } + log.Println("dry run complete") +} +``` + +Run: `cd server && go run ./cmd/migratecheck` + +Expected: per-collection rename counts, `0004: verified 17 collection(s)`, `dry run complete`, no error. + +- [ ] **Step 4: Confirm idempotency by hand** + +Run it a second time: `cd server && go run ./cmd/migratecheck` + +Expected: completes with no error and no rename counts (nothing left to rename). If it errors, the migration is not repeatable and a partial production run would be unrecoverable — fix before continuing. + +- [ ] **Step 5: Confirm it resumes from a partial run** + +Restore a fresh copy, then simulate a crash after the collection rename but before the field rename: + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_dryrun /tmp/dump/vantage +``` + +```javascript +db.adminCommand({renameCollection: "vantage_dryrun.orgs", to: "vantage_dryrun.instances"}) +``` + +Then: `cd server && go run ./cmd/migratecheck` + +Expected: completes cleanly and every document in `instances` carries `instance_id`. + +Confirm: + +```javascript +use vantage_dryrun +db.instances.countDocuments({instance_id: {$exists: true}}) +db.instances.countDocuments({org_id: {$exists: true}}) +``` + +Expected: the first equals the total document count; the second is `0`. + +- [ ] **Step 6: Delete the throwaway runner and commit** + +```bash +rm -rf server/cmd/migratecheck +git add server/internal/services/migrate_instance.go +git commit -m "feat(server): add migration 0004 org_id to instance_id" +``` + +Keep `/tmp/dump` — Task 8 needs it. + +--- + +### Task 4: Rename the control plane + +Mechanical, wide, and guarded by the compiler at every step. Work file by file and let `go build` drive. + +**Files:** +- Rename: `server/internal/services/orgs.go` → `instances.go` +- Rename: `server/internal/services/org_oidc.go` → `instance_oidc.go` +- Rename: `server/internal/auth/orghost.go` → `instancehost.go` +- Rename: `server/internal/models/org.go` → `instance.go` +- Modify: every file under `server/internal/` referencing a renamed symbol +- Modify: `server/cmd/main.go` + +**Interfaces:** +- Consumes: Task 1's shared API, Tasks 2–3's migration +- Produces: `services.GetInstance`, `GetInstanceBySlug`, `CreateInstance`, `ListInstanceIDs`, `CountInstances`, `FirstInstance`, `AdoptInstance`, `CountInstanceUsers`, `GetUserInInstance` — all with the same signatures as their `Org` predecessors + +- [ ] **Step 1: Rename the files** + +```bash +cd c:/Work/Repos/vantage +git mv server/internal/services/orgs.go server/internal/services/instances.go +git mv server/internal/services/org_oidc.go server/internal/services/instance_oidc.go +git mv server/internal/auth/orghost.go server/internal/auth/instancehost.go +git mv server/internal/models/org.go server/internal/models/instance.go +``` + +- [ ] **Step 2: Apply the identifier renames** + +Order matters — longest identifiers first, so shorter rules do not corrupt them. + +```bash +cd c:/Work/Repos/vantage/server +FILES=$(find . -name '*.go' -not -path './vendor/*') + +sed -i 's/CountOrgUsers/CountInstanceUsers/g' $FILES +sed -i 's/GetUserInOrg/GetUserInInstance/g' $FILES +sed -i 's/GetOrgBySlug/GetInstanceBySlug/g' $FILES +sed -i 's/ListOrgIDs/ListInstanceIDs/g' $FILES +sed -i 's/CountOrgs/CountInstances/g' $FILES +sed -i 's/FirstOrg/FirstInstance/g' $FILES +sed -i 's/AdoptOrg/AdoptInstance/g' $FILES +sed -i 's/CreateOrg/CreateInstance/g' $FILES +sed -i 's/GetOrg/GetInstance/g' $FILES +sed -i 's/OrgID/InstanceID/g' $FILES +sed -i 's/orgID/instanceID/g' $FILES +sed -i 's/models\.Org\b/models.Instance/g' $FILES +sed -i 's/"org_id"/"instance_id"/g' $FILES +sed -i 's/"orgs"/"instances"/g' $FILES +sed -i 's/"org_oidc"/"instance_oidc"/g' $FILES +``` + +- [ ] **Step 3: Restore the migration file** + +`sed` will have rewritten the migration's deliberate references to the old names, which would make it a no-op that silently does nothing. + +```bash +cd c:/Work/Repos/vantage +git checkout server/internal/services/migrate_instance.go +git diff server/internal/services/migrate_instance.go +``` + +Expected: no diff. The migration must keep reading `org_id`, `"orgs"` and `"org_oidc"` — that is its entire job. + +- [ ] **Step 4: Read the rest of the diff** + +`sed` is a blunt instrument. Run `git diff` and check for: + +- Log and error strings now reading "instance" where "organisation" was the correct customer-facing word. +- Comments that no longer parse as English. +- Any `instanceID` variable that was previously an unrelated `orgID` in a different sense. + +Fix anything that reads wrong before building. + +- [ ] **Step 5: Alias the model** + +Replace `server/internal/models/instance.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +// Instance is defined in the shared module because sitesvc and the admin +// control plane write the same documents. +type Instance = shared.Instance +``` + +- [ ] **Step 6: Build and fix what falls out** + +Run: `cd server && go build ./... 2>&1 | head -40` + +Expected initially: a list of errors. Work through them. Common ones: +- A server-only model still declaring `OrgID` — rename the field there too. +- `reservedSlugs` references in `AdoptInstance` — should already point at `provision.ReservedSlugs` from plan 0a. + +Repeat until the build is clean. + +- [ ] **Step 7: Rename the REST routes** + +In the router setup under `server/internal/api/`, change the route group path: + +```go + instanceGroup := api.Group("/instance", auth.RequireRole(models.RoleOwner, models.RoleAdmin)) + instanceGroup.GET("/users", listUsers) + instanceGroup.POST("/users", createUser) + instanceGroup.PUT("/users/:id/role", updateUserRole) + instanceGroup.DELETE("/users/:id", deleteUser) + instanceGroup.GET("/oidc", getOIDC) + instanceGroup.PUT("/oidc", putOIDC) +``` + +- [ ] **Step 8: Rename the session and /auth/me fields** + +In the session struct and the `/auth/me` handler, rename the JSON keys `org_id` to `instance_id` and `org` to `instance`. + +```bash +cd c:/Work/Repos/vantage +grep -rn '"org' server/internal/auth/ server/internal/api/ +``` + +Expected: no output. + +- [ ] **Step 9: Wire the migration and assertion into boot** + +In `server/cmd/main.go`, after the existing migrations (`MigrateMissedOrgScopes`, itself renamed by Step 2 to `MigrateMissedInstanceScopes`) and before the index builders: + +```go + migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute) + err = services.MigrateOrgToInstance(migCtx, db.Database) + migCancel() + if err != nil { + log.Fatalf("instance rename migration failed: %v", err) + } + + assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second) + err = services.AssertNoScopedCollectionMissed(assertCtx, db.Database) + assertCancel() + if err != nil { + log.Fatalf("scoped collection check failed: %v", err) + } +``` + +Both are fatal. A half-renamed database must not serve traffic. + +- [ ] **Step 10: Build, vet, and confirm the rename is total** + +```bash +cd c:/Work/Repos/vantage/server +go build ./... && go vet ./... +cd .. +grep -rn "org_id\|OrgID\|\"orgs\"\|/api/org" server/ | grep -v migrate_instance.go +``` + +Expected: no output from any of them. + +- [ ] **Step 11: Commit** + +```bash +git add server/ +git commit -m "refactor(server): rename Org to Instance" +``` + +--- + +### Task 5: The inverse rename + +The recovery path. Deliberately a separate one-shot command rather than a migration — the only reason to run it is a decision to revert the release, which is a human decision. + +**Files:** +- Create: `server/cmd/rename-rollback/main.go` + +**Interfaces:** +- Consumes: `services.ScopedCollections` +- Produces: a binary, not an API + +- [ ] **Step 1: Write it** + +Create `server/cmd/rename-rollback/main.go`: + +```go +// Command rename-rollback reverses migration 0004. +// +// Run it only as part of a decision to revert the release that introduced the +// instance rename. It renames instance_id back to org_id and restores the two +// collection names. Like the migration, it only renames — it deletes no +// documents. +// +// rename-rollback -uri mongodb://host:27017 -db vantage -confirm +package main + +import ( + "context" + "flag" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/services" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func main() { + uri := flag.String("uri", "mongodb://localhost:27017", "MongoDB URI") + dbName := flag.String("db", "vantage", "database name") + confirm := flag.Bool("confirm", false, "required; refuses to run without it") + flag.Parse() + + if !*confirm { + log.Fatal("refusing to run without -confirm") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + client, err := mongo.Connect(options.Client().ApplyURI(*uri)) + if err != nil { + log.Fatalf("connect: %v", err) + } + defer client.Disconnect(ctx) + + db := client.Database(*dbName) + + for _, c := range services.ScopedCollections { + res, err := db.Collection(c).UpdateMany(ctx, + bson.M{"instance_id": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"instance_id": "org_id"}}, + ) + if err != nil { + log.Fatalf("rename instance_id in %s: %v", c, err) + } + if res.ModifiedCount > 0 { + log.Printf("%s: reverted %d document(s)", c, res.ModifiedCount) + } + } + + for _, r := range []struct{ from, to string }{ + {"instances", "orgs"}, + {"instance_oidc", "org_oidc"}, + } { + cmd := bson.D{ + {Key: "renameCollection", Value: *dbName + "." + r.from}, + {Key: "to", Value: *dbName + "." + r.to}, + } + if err := client.Database("admin").RunCommand(ctx, cmd).Err(); err != nil { + log.Printf("rename %s to %s: %v (continuing)", r.from, r.to, err) + continue + } + log.Printf("renamed collection %s to %s", r.from, r.to) + } + + // Remove the marker so a redeployed new binary re-runs the migration. + if _, err := db.Collection("migrations").DeleteOne(ctx, bson.M{"_id": "0004_org_to_instance"}); err != nil { + log.Printf("clear migration marker: %v", err) + } + + log.Println("rollback complete") +} +``` + +- [ ] **Step 2: Build and confirm the guard** + +```bash +cd c:/Work/Repos/vantage/server +go build -o /tmp/rename-rollback ./cmd/rename-rollback +/tmp/rename-rollback +``` + +Expected: exits with `refusing to run without -confirm`. + +- [ ] **Step 3: Rehearse the round trip on the dry-run database** + +The dry-run database from Task 3 is already migrated. Revert it: + +```bash +/tmp/rename-rollback -uri mongodb://localhost:27017 -db vantage_dryrun -confirm +``` + +Then in `mongosh`: + +```javascript +use vantage_dryrun +db.getCollectionNames().sort() +db.orgs.countDocuments({org_id: {$exists: true}}) +db.orgs.countDocuments({instance_id: {$exists: true}}) +``` + +Expected: `orgs` and `org_oidc` are back, the first count equals the total document count, and the second is `0`. + +- [ ] **Step 4: Commit** + +```bash +git add server/cmd/rename-rollback +git commit -m "feat(server): add rename-rollback command for migration 0004" +``` + +--- + +### Task 6: Rename sitesvc and add its boot guard + +sitesvc writes the same documents. A version skew where it writes `org_id` while the control plane reads `instance_id` creates tenants the application cannot see — so it refuses to start against an unmigrated database. + +**Files:** +- Modify: `sitesvc/internal/models/models.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/api/*.go`, `sitesvc/cmd/main.go` + +**Interfaces:** +- Consumes: Task 1's shared API +- Produces: `store.RequireMigratedDatabase(ctx context.Context) error` + +- [ ] **Step 1: Rename the pending-signup field** + +In `sitesvc/internal/models/models.go`, replace the `OrgName` field with: + +```go + InstanceName string `bson:"instance_name"` +``` + +This collection is sitesvc-private and its records live at most 24 hours, so no migration is needed — an in-flight signup written under the old field name simply expires. That has a consequence for rollout; see the Rollout section. + +- [ ] **Step 2: Apply the identifier renames** + +```bash +cd c:/Work/Repos/vantage/sitesvc +FILES=$(find . -name '*.go') + +sed -i 's/CreateOrg/CreateInstance/g' $FILES +sed -i 's/RollbackOrg/RollbackInstance/g' $FILES +sed -i 's/OrgName/InstanceName/g' $FILES +sed -i 's/orgName/instanceName/g' $FILES +sed -i 's/OrgID/InstanceID/g' $FILES +sed -i 's/sharedmodels\.Org\b/sharedmodels.Instance/g' $FILES +sed -i 's/\borg\b/instance/g' $FILES +sed -i 's/"org_id"/"instance_id"/g' $FILES +sed -i 's/"orgs"/"instances"/g' $FILES +sed -i 's/"org_name"/"instance_name"/g' $FILES +``` + +Read the diff. `\borg\b` is the aggressive rule — check it did not rename an unrelated local variable or mangle a comment. Also check the JSON request field in `internal/api`: the signup form posts `org_name`, so the marketing site's field name and the handler's binding tag must change together (Task 8 handles the site side). + +- [ ] **Step 3: Write the boot guard** + +Add to `sitesvc/internal/store/store.go`: + +```go +// RequireMigratedDatabase refuses to start against a control-plane database +// that has not run migration 0004. +// +// Provisioning into `orgs` while the control plane reads `instances` would +// create tenants nobody can see — the exact skew failure the shared module was +// built to prevent. Failing to start is strictly better. +func RequireMigratedDatabase(ctx context.Context) error { + names, err := database.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + var hasInstances, hasOrgs bool + for _, n := range names { + switch n { + case "instances": + hasInstances = true + case "orgs": + hasOrgs = true + } + } + + // A brand-new database has neither. That is fine — whichever service starts + // first creates `instances`. + if !hasInstances && !hasOrgs { + return nil + } + if !hasInstances { + return errors.New("instances collection not found; deploy the control plane first") + } + return nil +} +``` + +- [ ] **Step 4: Call it at boot** + +In `sitesvc/cmd/main.go`, immediately after the successful `store.Connect` and before `store.EnsureIndexes`: + +```go + guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second) + err := store.RequireMigratedDatabase(guardCtx) + guardCancel() + if err != nil { + log.Fatalf("database check failed: %v", err) + } +``` + +Note `main` currently uses `:=` for its first `err`; adjust to `=` or `:=` as the surrounding code requires — `go build` will tell you. + +- [ ] **Step 5: Build and confirm** + +```bash +cd c:/Work/Repos/vantage/sitesvc +go build ./... && go vet ./... +cd .. +grep -rn "org_id\|OrgID\|OrgName\|\"orgs\"\|org_name" sitesvc/ +``` + +Expected: no output. + +- [ ] **Step 6: Exercise the guard by hand** + +Against the un-migrated copy — restore one: + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_guardtest /tmp/dump/vantage +``` + +```bash +cd c:/Work/Repos/vantage/sitesvc +MONGO_URI=mongodb://localhost:27017/vantage_guardtest \ + PUBLIC_URL=http://localhost:8082 SITE_ORIGIN=http://localhost:3001 \ + SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=noreply@example.com \ + go run ./cmd +``` + +Expected: exits immediately with +`database check failed: instances collection not found; deploy the control plane first` + +Then migrate that database (boot the server against it) and run sitesvc again. + +Expected: starts normally and logs `sitesvc listening on :8082`. + +- [ ] **Step 7: Commit** + +```bash +git add sitesvc/ +git commit -m "refactor(sitesvc): rename Org to Instance, refuse an unmigrated database" +``` + +--- + +### Task 7: Rename the control-plane frontend + +Breaking route changes ship here, in the same release as Task 4. + +**Files:** +- Modify: `web/lib/` API client, every route under `web/app/(app)/`, `web/components/` +- Rename: `web/app/(app)/settings/org/` → `web/app/(app)/settings/instance/` + +**Interfaces:** +- Consumes: the renamed REST API from Task 4 +- Produces: no API + +- [ ] **Step 1: Find every reference and save the list** + +```bash +cd c:/Work/Repos/vantage/web +grep -rn "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization\|settings/org" \ + --include=*.ts --include=*.tsx . | tee /tmp/web-org-refs.txt +wc -l /tmp/web-org-refs.txt +``` + +Every line must be resolved by Step 5. + +- [ ] **Step 2: Rename the settings route** + +```bash +cd c:/Work/Repos/vantage +git mv "web/app/(app)/settings/org" "web/app/(app)/settings/instance" +``` + +- [ ] **Step 3: Apply the renames** + +```bash +cd c:/Work/Repos/vantage/web +FILES=$(grep -rl "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization\|settings/org" \ + --include=*.ts --include=*.tsx .) + +sed -i 's|/api/org/|/api/instance/|g' $FILES +sed -i 's/org_id/instance_id/g' $FILES +sed -i 's/orgId/instanceId/g' $FILES +sed -i 's|settings/org|settings/instance|g' $FILES +sed -i 's/Organisation/Instance/g' $FILES +sed -i 's/Organization/Instance/g' $FILES +sed -i 's/organisation/instance/g' $FILES +sed -i 's/organization/instance/g' $FILES +``` + +- [ ] **Step 4: Read every copy change** + +The copy substitutions are the ones most likely to produce nonsense. Run `git diff` and read **every changed user-facing string**. "Instance name too short" is fine; "Create your instance" is fine; a sentence that only worked with the old word needs rewriting rather than substituting. Fix them now — nobody else will. + +- [ ] **Step 5: Confirm nothing was missed** + +```bash +cd c:/Work/Repos/vantage/web +grep -rn "org_id\|orgId\|/api/org\|Organisation\|Organization\|organisation\|organization" \ + --include=*.ts --include=*.tsx . +``` + +Expected: no output. + +- [ ] **Step 6: Build** + +Run: `cd web && npm run build` +Expected: build succeeds with no type errors. + +- [ ] **Step 7: Commit** + +```bash +git add web/ +git commit -m "refactor(web): rename Organisation to Instance" +``` + +--- + +### Task 8: Marketing site copy + +The marketing site uses "organisation" for two different things, and only one becomes "Instance". Where it means the customer, it becomes **Account** — a word that now has a specific meaning in this system, and the marketing site is where a customer meets it first. + +**Files:** +- Modify: `site/app/`, `site/components/` + +- [ ] **Step 1: Find every reference** + +```bash +cd c:/Work/Repos/vantage/site +grep -rn "organisation\|Organisation\|organization\|Organization\|org_name" \ + --include=*.ts --include=*.tsx --include=*.md . +``` + +- [ ] **Step 2: Decide each one individually** + +**Do not bulk-substitute here.** For each hit, decide: + +- The thing that gets a subdomain, holds servers and carries a licence → **Instance** +- The customer who pays and may hold several → **Account** + +- [ ] **Step 3: Update the signup form** + +This is the case that matters most and the only functional change in this task. The form currently asks for an "organisation name" and posts `org_name`; that name becomes the slug, so it is creating an **Instance**. + +- Field label becomes "Instance name" +- Helper text explains it becomes the subdomain +- The posted JSON field becomes `instance_name`, matching the sitesvc handler renamed in Task 6 Step 2 + +- [ ] **Step 4: Build** + +Run: `cd site && npm run build` +Expected: succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add site/ +git commit -m "refactor(site): distinguish Instance from Account in copy" +``` + +--- + +### Task 9: Full verification against a production snapshot + +The gate. With no automated tests, this is the entire safety net. Nothing ships until every step passes against **restored production data**. + +**Files:** none + +- [ ] **Step 1: Build everything, including the agent** + +```bash +cd c:/Work/Repos/vantage +(cd shared && go build ./... && go vet ./...) +(cd server && go build ./... && go vet ./...) +(cd sitesvc && go build ./... && go vet ./...) +(cd agent && go build ./... && go vet ./...) +(cd web && npm run build) +(cd site && npm run build) +``` + +Expected: all succeed. + +- [ ] **Step 2: Restore a production snapshot and record the baseline** + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_snapshot /tmp/dump/vantage +``` + +In `mongosh`: + +```javascript +use vantage_snapshot +db.getCollectionNames().sort().forEach(function (c) { + print(c + " " + db.getCollection(c).countDocuments({})) +}) +``` + +Save the output to `/tmp/baseline.txt`. Everything below compares against it. + +- [ ] **Step 3: Record the per-tenant baseline** + +Pick three real tenants from `db.orgs.find({}, {org_id: 1})`. For each: + +```javascript +["", "", ""].forEach(function (t) { + print(t + + " servers=" + db.servers.countDocuments({org_id: t}) + + " keys=" + db.keys.countDocuments({org_id: t}) + + " workflows=" + db.workflows.countDocuments({org_id: t}) + + " monitors=" + db.monitors.countDocuments({org_id: t}) + + " secrets=" + db.secrets.countDocuments({org_id: t}) + + " audit=" + db.audit_logs.countDocuments({org_id: t})) +}) +``` + +Save to `/tmp/tenants-before.txt`. + +- [ ] **Step 4: Run the migration** + +Boot the server against the snapshot: + +```bash +cd c:/Work/Repos/vantage/server +MONGO_URI=mongodb://localhost:27017 MONGO_DB=vantage_snapshot \ + GRPC_HOST=localhost:9090 go run ./cmd +``` + +Expected in the logs: `0004: renamed collection orgs to instances`, `0004: renamed collection org_oidc to instance_oidc`, per-collection rename counts, and `0004: verified 17 collection(s)`. No fatal error, and no `scoped collection check failed`. + +- [ ] **Step 5: Compare document counts** + +Re-run the Step 2 snippet and diff against the baseline: + +```javascript +use vantage_snapshot +db.getCollectionNames().sort().forEach(function (c) { + print(c + " " + db.getCollection(c).countDocuments({})) +}) +``` + +Expected: identical counts, with exactly two names changed — `orgs` now `instances`, `org_oidc` now `instance_oidc`. **Any count difference is a stop-the-release defect.** + +- [ ] **Step 6: The tenant isolation test** + +For the same three tenants: + +```javascript +["", "", ""].forEach(function (t) { + print(t + + " servers=" + db.servers.countDocuments({instance_id: t}) + + " keys=" + db.keys.countDocuments({instance_id: t}) + + " workflows=" + db.workflows.countDocuments({instance_id: t}) + + " monitors=" + db.monitors.countDocuments({instance_id: t}) + + " secrets=" + db.secrets.countDocuments({instance_id: t}) + + " audit=" + db.audit_logs.countDocuments({instance_id: t})) +}) +``` + +Expected: byte-identical to `/tmp/tenants-before.txt` apart from the field name in the query. **This is the step that proves tenant isolation survived.** Anything else stops the release. + +- [ ] **Step 7: Confirm no document kept the old field** + +```javascript +db.getCollectionNames().forEach(function (c) { + var n = db.getCollection(c).countDocuments({org_id: {$exists: true}}); + if (n > 0) print("STALE " + c + " " + n); +}) +``` + +Expected: no output. + +- [ ] **Step 8: Application smoke test** + +Against the migrated snapshot, with `web` running: + +1. Log in as a real user. +2. Confirm the servers list, keys, workflows, monitors, secrets and audit log all populate with the expected number of rows. +3. Open a server detail page; confirm inventory and assigned keys render. +4. Open `/settings/instance`; confirm members and OIDC settings load. +5. Confirm the sidebar and page copy say "Instance", never "Organisation". +6. Create something — a monitor is cheapest — and confirm it saves and appears. + +- [ ] **Step 9: sitesvc against the migrated database** + +Boot sitesvc against `vantage_snapshot` and complete a signup end to end: form, verification email via MailHog, link, then log into the control plane with the new credentials. + +Expected: succeeds, and the new instance appears in `db.instances`. + +- [ ] **Step 10: sitesvc against an unmigrated database** + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_unmigrated /tmp/dump/vantage +``` + +Boot sitesvc against it. + +Expected: exits immediately with +`database check failed: instances collection not found; deploy the control plane first` + +- [ ] **Step 11: Rollback rehearsal** + +**Mandatory.** Do not deploy without having done this. + +```bash +mongorestore --drop --uri mongodb://localhost:27017 --db vantage_rollback /tmp/dump/vantage +``` + +Migrate it (boot the server against it), then revert: + +```bash +/tmp/rename-rollback -uri mongodb://localhost:27017 -db vantage_rollback -confirm +``` + +Then: + +1. Re-run the Step 2 count snippet against `vantage_rollback` and diff against `/tmp/baseline.txt`. Expected: identical, including the original collection names. +2. Re-run the Step 3 per-tenant snippet with `org_id`. Expected: identical to `/tmp/tenants-before.txt`. +3. Check out the **pre-release** commit, build the old server, and boot it against `vantage_rollback`. Expected: starts and serves normally. + +- [ ] **Step 12: Confirm the agent is untouched** + +```bash +cd c:/Work/Repos/vantage +git diff --name-only main -- agent/ .gitea/workflows/agent-release.yml +``` + +Expected: no output. + +- [ ] **Step 13: Clean up and commit** + +```javascript +["vantage_snapshot","vantage_dryrun","vantage_guardtest","vantage_unmigrated","vantage_rollback"] + .forEach(function (d) { db.getSiblingDB(d).dropDatabase() }) +``` + +```bash +git add -A +git commit -m "chore: verify instance rename against a production snapshot" +``` + +--- + +## Rollout + +**Order matters. Read this before deploying.** + +1. **Take a database backup.** Not optional. +2. Confirm the Task 9 rollback rehearsal was actually performed, not just read. +3. Deploy `server`, `web`, `site` and `sitesvc` from **one commit, together**. +4. The server boots first, runs migration `0004`, records the marker. +5. sitesvc may start before the server and exit with the guard message. It restarts and succeeds once the migration has run. **This is expected, not an incident.** + +```bash +cd /opt/vantage && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans +``` + +Expect a short API outage during the server restart while the migration runs. Agents are unaffected — they reconnect, and no gRPC message carries a tenant ID. + +**In-flight signups are lost.** Any unverified signup recorded before the deploy carries `org_name` and will fail verification. There are at most 24 hours' worth. Either accept it, or wait for the collection to drain: + +```javascript +db.site_pending_signups.countDocuments({}) +``` + +Post-deploy checks: + +1. Control-plane login works and the fleet dashboard populates. +2. A fresh signup completes end to end. +3. Server logs show `0004: verified 17 collection(s)` and no scoped-collection failure. +4. `db.servers.countDocuments({status: "active"})` matches the pre-deploy figure — agents are still reporting. + +**If something is wrong:** deploy the previous images, run `rename-rollback -confirm`, and confirm the old binaries boot. Do not attempt a partial fix against a live half-renamed database. + +## What this unblocks + +Plan 1 (`licensing-core`) can define a licence payload bound to `instance_id` +without inventing a word the codebase does not use. diff --git a/docs/superpowers/plans/2026-07-24-shared-module.md b/docs/superpowers/plans/2026-07-24-shared-module.md new file mode 100644 index 0000000..1e73b02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-shared-module.md @@ -0,0 +1,1341 @@ +# Shared Module Extraction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract a `shared` Go module holding the document shapes and provisioning rules that `server` and `sitesvc` both write, deleting the hand-copied duplicates in sitesvc. + +**Architecture:** A new Go module `github.com/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve. + +**Tech Stack:** Go 1.26, MongoDB driver v2, `golang.org/x/crypto/bcrypt`, `github.com/google/uuid`, Docker, Gitea Actions. + +**No automated tests.** Verification is by compiler, `grep`, and running the two services end to end against a scratch database. Every task ends with checks that produce observable output. + +## Global Constraints + +- Go version in every `go.mod`: `go 1.26` +- Module path: `github.com/mrhid6/vantage/shared` +- `shared/go.mod` may require **only** these three: `go.mongodb.org/mongo-driver/v2`, `golang.org/x/crypto`, `github.com/google/uuid`. Any addition needs review — this constraint is what keeps sitesvc small. +- `MinSlugLength = 3`, `MaxSlugLength = 40`, `BcryptCost = 12` — exact values, no literals elsewhere. +- Reserved slugs, exactly: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, `default` +- User-facing error text uses **"organisation"** (British spelling) everywhere. The control plane currently says "organization"; that changes in this plan. +- **No renaming.** `Org`, `OrgID`, `org_id`, `orgs` stay exactly as they are. Renaming is plan 0b. +- **No behaviour changes** other than the two explicitly listed in Task 3. +- The `agent` module is not touched by any task in this plan. +- A local MongoDB is needed for the end-to-end checks: `docker run -d -p 27017:27017 --name vantage-dev-mongo mongo:7` + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `go.work` | Workspace over shared, server, sitesvc | +| `shared/go.mod`, `shared/go.sum` | Module definition | +| `shared/models/org.go` | `Org` document | +| `shared/models/user.go` | `User` document, role constants, `ValidRole` | +| `shared/models/settings.go` | `Settings` and sub-structs | +| `shared/provision/slug.go` | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants | +| `shared/provision/org.go` | `CreateOrg`, `RollbackOrg` | +| `shared/provision/user.go` | `CreateUser`, `CreateUserWithHash`, `BcryptCost` | +| `shared/indexes/indexes.go` | `EnsureCoreIndexes` | + +**Modified:** + +| Path | Change | +|---|---| +| `server/go.mod` | require + replace shared | +| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared | +| `server/internal/services/orgs.go` | `CreateOrg` delegates to shared | +| `server/internal/services/users.go` | `CreateUser` delegates to shared | +| `server/internal/services/migrate.go` | `EnsureAuthIndexes` delegates to shared | +| `server/Dockerfile` | Build from repo root | +| `sitesvc/go.mod` | require + replace shared | +| `sitesvc/internal/models/models.go` | Reduced to `PendingSignup` | +| `sitesvc/internal/store/store.go` | Uses shared provision | +| `sitesvc/Dockerfile` | Build from repo root | +| `.gitea/workflows/server-deploy.yml` | Root context for the two Go images | + +**Deleted:** + +| Path | +|---| +| `sitesvc/internal/provision/provision.go` | + +--- + +### Task 1: Scaffold the shared module + +**Files:** +- Create: `shared/go.mod` +- Create: `go.work` + +**Interfaces:** +- Consumes: nothing +- Produces: an importable but empty module + +- [ ] **Step 1: Create the module** + +```bash +cd c:/Work/Repos/vantage +mkdir -p shared +cd shared +go mod init github.com/mrhid6/vantage/shared +go get go.mongodb.org/mongo-driver/v2@latest +go get golang.org/x/crypto@latest +go get github.com/google/uuid@latest +``` + +- [ ] **Step 2: Create the workspace** + +Create `go.work` at the repo root: + +``` +go 1.26 + +use ( + ./shared + ./server + ./sitesvc +) +``` + +`./agent` is deliberately absent. The agent stays standalone with its own release pipeline. + +- [ ] **Step 3: Verify the workspace resolves** + +Run: `cd c:/Work/Repos/vantage && go work sync && go list -m all | head -5` +Expected: output includes `github.com/mrhid6/vantage/shared`, `github.com/mrhid6/vantage/server` and `github.com/mrhid6/vantage/sitesvc`. No error. + +- [ ] **Step 4: Commit** + +```bash +git add go.work shared/go.mod shared/go.sum +git commit -m "chore: scaffold shared module" +``` + +--- + +### Task 2: Slug rules and models + +Pure definitions with no database access. Grouped into one task because neither is independently reviewable — a struct with no consumer and a regex with no caller are the same review. + +**Files:** +- Create: `shared/provision/slug.go` +- Create: `shared/models/org.go` +- Create: `shared/models/user.go` +- Create: `shared/models/settings.go` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `const MinSlugLength = 3`, `MaxSlugLength = 40` + - `var ReservedSlugs map[string]bool` + - `func Slugify(name string) string` + - `func BaseSlug(name string) (string, error)` + - `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2` + - `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings` + - `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool` + +- [ ] **Step 1: Write the slug rules** + +Create `shared/provision/slug.go`: + +```go +// Package provision holds the tenant creation rules shared by the control +// plane and sitesvc. +// +// These rules used to be duplicated: the control plane owned one copy and +// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried +// on a lost slug race while the control plane returned an error. This package +// is the single definition; neither service may reimplement any of it. +package provision + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + MinSlugLength = 3 + MaxSlugLength = 40 +) + +var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) + +// ReservedSlugs are subdomain labels the platform needs for itself. +var ReservedSlugs = map[string]bool{ + "www": true, "api": true, "app": true, "admin": true, "auth": true, + "install": true, "static": true, "_next": true, "default": true, +} + +// Slugify lowercases a name and collapses every run of non-alphanumeric +// characters into a single hyphen, trimming hyphens from both ends. +func Slugify(name string) string { + s := strings.ToLower(name) + s = slugStrip.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +// BaseSlug turns a name into a validated slug stem, or explains why it cannot. +func BaseSlug(name string) (string, error) { + base := Slugify(name) + if len(base) < MinSlugLength { + return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength) + } + if len(base) > MaxSlugLength { + base = base[:MaxSlugLength] + } + if ReservedSlugs[base] { + return "", fmt.Errorf("that organisation name is reserved") + } + return base, nil +} + +// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the +// base itself; later attempts append a counter. +func NextSlug(base string, attempt int) string { + if attempt < 2 { + return base + } + return fmt.Sprintf("%s-%d", base, attempt) +} +``` + +- [ ] **Step 2: Create the Org model** + +Create `shared/models/org.go`: + +```go +// Package models holds the MongoDB documents written by more than one Vantage +// service. Documents only the control plane touches stay in +// server/internal/models. +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Org struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} +``` + +- [ ] **Step 3: Create the User model** + +Create `shared/models/user.go`: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +func ValidRole(role string) bool { + switch role { + case RoleOwner, RoleAdmin, RoleMember: + return true + } + return false +} + +type User struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + UserID string `bson:"user_id" json:"user_id"` + OrgID string `bson:"org_id" json:"org_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash,omitempty" json:"-"` + Role string `bson:"role" json:"role"` + AuthSource string `bson:"auth_source" json:"auth_source"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` +} +``` + +- [ ] **Step 4: Create the Settings model** + +Create `shared/models/settings.go`: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AlertSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + WebhookURL string `bson:"webhook_url" json:"webhook_url"` + OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"` +} + +type EmailSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + SMTPHost string `bson:"smtp_host" json:"smtp_host"` + SMTPPort int `bson:"smtp_port" json:"smtp_port"` + Username string `bson:"username" json:"username"` + Password string `bson:"password" json:"password"` + FromAddr string `bson:"from_addr" json:"from_addr"` + ToAddrs []string `bson:"to_addrs" json:"to_addrs"` + UseTLS bool `bson:"use_tls" json:"use_tls"` +} + +type SecretsSettings struct { + ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"` + ReadTokenSet bool `bson:"-" json:"read_token_set"` + RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"` +} + +type Settings struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` + Alerts AlertSettings `bson:"alerts" json:"alerts"` + Email EmailSettings `bson:"email" json:"email"` + Secrets SecretsSettings `bson:"secrets" json:"secrets"` + + WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` +} +``` + +- [ ] **Step 5: Verify the bson tags match production exactly** + +Without tests, this diff is the only thing standing between you and orphaned production data. A changed bson tag means every existing document silently stops matching. + +```bash +cd c:/Work/Repos/vantage +diff <(grep -o 'bson:"[^"]*"' server/internal/models/org.go) \ + <(grep -o 'bson:"[^"]*"' shared/models/org.go) +diff <(grep -o 'bson:"[^"]*"' server/internal/models/user.go) \ + <(grep -o 'bson:"[^"]*"' shared/models/user.go) +diff <(grep -o 'bson:"[^"]*"' server/internal/models/settings.go) \ + <(grep -o 'bson:"[^"]*"' shared/models/settings.go) +``` + +Expected: **no output from any of the three.** Any output is a defect — stop and fix it before continuing. + +- [ ] **Step 6: Sanity-check the slug rules by hand** + +Create a throwaway `shared/cmd/slugcheck/main.go`: + +```go +package main + +import ( + "fmt" + + "github.com/mrhid6/vantage/shared/provision" +) + +func main() { + for _, n := range []string{"Acme", "Acme Corp", "ACME CORP", "Acme & Co.", " Acme ", "---Acme---", "!!!", "ab", "admin"} { + base, err := provision.BaseSlug(n) + fmt.Printf("%-14q slugify=%-12q base=%-12q err=%v\n", n, provision.Slugify(n), base, err) + } + fmt.Println(provision.NextSlug("acme", 1), provision.NextSlug("acme", 2), provision.NextSlug("acme", 3)) +} +``` + +Run: `cd shared && go run ./cmd/slugcheck` + +Expected output: + +``` +"Acme" slugify="acme" base="acme" err= +"Acme Corp" slugify="acme-corp" base="acme-corp" err= +"ACME CORP" slugify="acme-corp" base="acme-corp" err= +"Acme & Co." slugify="acme-co" base="acme-co" err= +" Acme " slugify="acme" base="acme" err= +"---Acme---" slugify="acme" base="acme" err= +"!!!" slugify="" base="" err=organisation name too short (slug must be at least 3 characters) +"ab" slugify="ab" base="" err=organisation name too short (slug must be at least 3 characters) +"admin" slugify="admin" base="" err=that organisation name is reserved +acme acme-2 acme-3 +``` + +Then delete it: `rm -rf shared/cmd/slugcheck` + +- [ ] **Step 7: Build and commit** + +```bash +cd c:/Work/Repos/vantage/shared && go build ./... && go vet ./... +cd .. +git add shared/provision/slug.go shared/models +git commit -m "feat(shared): add slug rules and shared document models" +``` + +--- + +### Task 3: Provisioning + +The single `CreateOrg`, `RollbackOrg` and `CreateUser`. **Two deliberate behaviour changes**, both adopting sitesvc's version because it is the correct one: + +1. On a duplicate-key race the slug loop **retries the next slug** instead of returning an error. The control plane previously failed the request. +2. Error text uses "organisation", not "organization". + +**Files:** +- Create: `shared/provision/org.go` +- Create: `shared/provision/user.go` + +**Interfaces:** +- Consumes: `models.Org`, `models.User`, `models.ValidRole`, `provision.BaseSlug`, `provision.NextSlug` +- Produces: + - `var ErrNameRejected error`, `var ErrEmailTaken error` + - `const BcryptCost = 12` + - `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)` + - `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error` + - `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)` + - `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)` + +`CreateUserWithHash` exists because sitesvc hashes the password at signup time and stores the hash in the pending record; by verification time it holds a hash, not a password. Without it sitesvc would have to insert the document by hand, which is the duplication this plan removes. + +- [ ] **Step 1: Write org provisioning** + +Create `shared/provision/org.go`: + +```go +package provision + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ErrNameRejected wraps every reason a name cannot become an organisation. +var ErrNameRejected = errors.New("organisation name rejected") + +const maxSlugAttempts = 50 + +// CreateOrg inserts an organisation under the first free slug derived from name. +// +// The count-then-insert loop is racy on its own. It is safe only because +// orgs.slug carries a unique index: a lost race surfaces as a duplicate-key +// error, which we treat as "that slug is taken" and retry. Do not remove the +// duplicate-key branch, and do not remove the index. +func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error) { + base, err := BaseSlug(name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) + } + + for attempt := 1; attempt <= maxSlugAttempts; attempt++ { + slug := NextSlug(base, attempt) + + n, err := db.Collection("orgs").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n > 0 { + continue + } + + org := models.Org{ + OrgID: uuid.NewString(), + Name: name, + Slug: slug, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("orgs").InsertOne(ctx, org); err != nil { + if mongo.IsDuplicateKeyError(err) { + continue // lost the race; try the next slug + } + return nil, err + } + return &org, nil + } + return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) +} + +// RollbackOrg deletes an organisation that has no users. +// +// It refuses an organisation that has users. Rollback exists to clean up a +// half-finished signup, and an organisation with users is not half-finished. +func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error { + n, err := db.Collection("users").CountDocuments(ctx, bson.M{"org_id": orgID}) + if err != nil { + return err + } + if n > 0 { + return fmt.Errorf("refusing to roll back organisation %s: it has %d user(s)", orgID, n) + } + _, err = db.Collection("orgs").DeleteOne(ctx, bson.M{"org_id": orgID}) + return err +} +``` + +- [ ] **Step 2: Write user provisioning** + +Create `shared/provision/user.go`: + +```go +package provision + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +// BcryptCost is the work factor for every password hash Vantage writes. +// Changing it changes nothing about existing hashes, which carry their own cost. +const BcryptCost = 12 + +// ErrEmailTaken is returned when the unique index on users.email rejects an insert. +var ErrEmailTaken = errors.New("email already registered") + +// CreateUser hashes password and inserts the user. An empty password leaves the +// hash empty, which is how OIDC users are stored. +func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error) { + var hash string + if password != "" { + b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return nil, err + } + hash = string(b) + } + return CreateUserWithHash(ctx, db, orgID, email, hash, role, authSource) +} + +// CreateUserWithHash inserts a user whose password was already hashed +// elsewhere. sitesvc hashes at signup and only holds the hash by the time the +// verification link is opened. +func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + if !models.ValidRole(role) { + return nil, fmt.Errorf("invalid role %q", role) + } + + u := &models.User{ + UserID: uuid.NewString(), + OrgID: orgID, + Email: email, + PasswordHash: passwordHash, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, ErrEmailTaken + } + return nil, err + } + return u, nil +} +``` + +- [ ] **Step 3: Build** + +Run: `cd shared && go build ./... && go vet ./...` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add shared/provision/org.go shared/provision/user.go +git commit -m "feat(shared): add CreateOrg, RollbackOrg and CreateUser + +Adopts sitesvc's retry-on-duplicate-key slug loop. The control plane +previously returned an error when it lost the slug race." +``` + +--- + +### Task 4: Core indexes + +`users.email` and `orgs.slug` unique indexes are a tenant-isolation property, not an optimisation — `GetUserByEmail` does an unscoped `FindOne`, so a duplicate email would let the OIDC cross-org guard compare against an arbitrary user. Both services declare them, and both treat failure as fatal. + +**Files:** +- Create: `shared/indexes/indexes.go` + +**Interfaces:** +- Consumes: nothing +- Produces: `func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error` + +- [ ] **Step 1: Write it** + +Create `shared/indexes/indexes.go`: + +```go +// Package indexes declares the MongoDB indexes more than one Vantage service +// depends on. +package indexes + +import ( + "context" + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug. +// +// These are a security property, not an optimisation. GetUserByEmail does an +// unscoped FindOne, so a duplicate email would let the OIDC cross-org guard +// compare against an arbitrary user. Every caller must treat a failure here as +// fatal. +// +// Creating an index that already exists with the same specification is a no-op, +// so this is safe to call at every boot from every service. +func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error { + if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("users.email index: %w", err) + } + + if _, err := db.Collection("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("orgs.slug index: %w", err) + } + + return nil +} +``` + +- [ ] **Step 2: Build and confirm the dependency list is still clean** + +```bash +cd c:/Work/Repos/vantage/shared +go build ./... && go vet ./... +go list -m all | grep -Ev "^github.com/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org" +``` + +Expected: no output from the build, and no unexpected module from the list. Gin, redis or guac appearing means something was moved into `shared` that should not have been. + +- [ ] **Step 3: Commit** + +```bash +git add shared/indexes +git commit -m "feat(shared): add EnsureCoreIndexes" +``` + +--- + +### Task 5: Wire the control plane to shared + +Server keeps its own package paths so no call site outside these files changes. `server/internal/models` re-exports the shared types as aliases — a type alias is identical to the aliased type, so `models.Org` in existing server code keeps working untouched. + +**Files:** +- Modify: `server/go.mod` +- Modify: `server/internal/models/org.go`, `user.go`, `settings.go` +- Modify: `server/internal/services/orgs.go` (`CreateOrg`, `AdoptOrg`, delete `reservedSlugs`) +- Modify: `server/internal/services/users.go` (`CreateUser`) +- Modify: `server/internal/services/migrate.go` (`EnsureAuthIndexes`) + +**Interfaces:** +- Consumes: everything from Tasks 2–4 +- Produces: no new exported API. `services.CreateOrg(name string) (*models.Org, error)` and `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` keep their exact signatures. + +- [ ] **Step 1: Add the dependency** + +```bash +cd c:/Work/Repos/vantage/server +go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0 +go mod edit -replace=github.com/mrhid6/vantage/shared=../shared +go mod tidy +``` + +- [ ] **Step 2: Alias the models** + +Replace the entire contents of `server/internal/models/org.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +// Org is defined in the shared module because sitesvc writes the same +// documents. Aliased rather than re-declared so existing call sites are +// unchanged and the two services cannot drift. +type Org = shared.Org +``` + +Replace the entire contents of `server/internal/models/user.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +type User = shared.User + +const ( + RoleOwner = shared.RoleOwner + RoleAdmin = shared.RoleAdmin + RoleMember = shared.RoleMember +) + +func ValidRole(role string) bool { return shared.ValidRole(role) } +``` + +Replace the entire contents of `server/internal/models/settings.go` with: + +```go +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +type ( + Settings = shared.Settings + AlertSettings = shared.AlertSettings + EmailSettings = shared.EmailSettings + SecretsSettings = shared.SecretsSettings +) +``` + +- [ ] **Step 3: Verify the build still passes** + +Run: `cd server && go build ./...` +Expected: no output. If anything fails, an alias is missing — add it rather than reverting. + +- [ ] **Step 4: Delegate CreateOrg** + +In `server/internal/services/orgs.go`, delete the `reservedSlugs` package variable and replace the whole `CreateOrg` function with: + +```go +// CreateOrg creates an organisation and seeds its default workflow steps. +// +// The creation rules live in shared/provision because sitesvc creates +// organisations too. Seeding stays here: shared must not know about workflow +// steps. +func CreateOrg(name string) (*models.Org, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + o, err := provision.CreateOrg(ctx, db.Database, name) + if err != nil { + return nil, err + } + + if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil { + log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err) + } else { + log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated) + } + return o, nil +} +``` + +`AdoptOrg` also references `reservedSlugs` and `Slugify` and hard-codes the lengths `3` and `40`. Change its references to `provision.ReservedSlugs`, `provision.Slugify`, `provision.MinSlugLength` and `provision.MaxSlugLength`. + +Add to the import block: + +```go + "github.com/mrhid6/vantage/shared/provision" +``` + +Remove `"github.com/google/uuid"` and `"go.mongodb.org/mongo-driver/v2/mongo"` **only if** nothing else in the file still uses them — `go build` will tell you. + +- [ ] **Step 5: Delegate CreateUser** + +In `server/internal/services/users.go`, replace the whole `CreateUser` function with: + +```go +func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + u, err := provision.CreateUser(ctx, db.Database, orgID, email, password, role, authSource) + if errors.Is(err, provision.ErrEmailTaken) { + return nil, fmt.Errorf("email already registered") + } + return u, err +} +``` + +The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"github.com/mrhid6/vantage/shared/provision"` to the imports. + +- [ ] **Step 6: Delegate EnsureAuthIndexes** + +In `server/internal/services/migrate.go`, replace the body of `EnsureAuthIndexes` with: + +```go +func EnsureAuthIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return indexes.EnsureCoreIndexes(ctx, db.Database) +} +``` + +Add `"github.com/mrhid6/vantage/shared/indexes"` to the imports. + +- [ ] **Step 7: Build and vet** + +```bash +cd c:/Work/Repos/vantage/server +go build ./... && go vet ./... +``` + +Expected: no output from either. + +- [ ] **Step 8: Confirm no duplicated rules remain in the server** + +```bash +cd c:/Work/Repos/vantage +grep -rn "reservedSlugs\|bcrypt.GenerateFromPassword\|func Slugify" server/internal/ +``` + +Expected: no hits in `services/orgs.go` or `services/users.go`. Hits elsewhere — a password-change handler, for example — are fine **only** if they use `provision.BcryptCost` rather than a literal `12`. Fix any that do not. + +- [ ] **Step 9: Commit** + +```bash +git add server/go.mod server/go.sum server/internal/models server/internal/services +git commit -m "refactor(server): use shared models, provision and indexes" +``` + +--- + +### Task 6: Wire sitesvc to shared and delete the duplicates + +**Files:** +- Modify: `sitesvc/go.mod` +- Modify: `sitesvc/internal/models/models.go` +- Modify: `sitesvc/internal/store/store.go` +- Delete: `sitesvc/internal/provision/provision.go` + +**Interfaces:** +- Consumes: everything from Tasks 2–4 +- Produces: `store.Verify(ctx, rawToken) (*sharedmodels.Org, error)`. `store.CreatePending`, `store.EmailTaken`, `store.Connect`, `store.EnsureIndexes` keep their signatures. + +- [ ] **Step 1: Add the dependency** + +```bash +cd c:/Work/Repos/vantage/sitesvc +go mod edit -require=github.com/mrhid6/vantage/shared@v0.0.0 +go mod edit -replace=github.com/mrhid6/vantage/shared=../shared +go mod tidy +``` + +- [ ] **Step 2: Reduce the models file** + +Replace the entire contents of `sitesvc/internal/models/models.go` with: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// PendingSignup lives here rather than in the shared module because only +// sitesvc writes site_pending_signups. The control plane does not know the +// collection exists. +// +// Org and User used to be mirrored here by hand. They now come from +// github.com/mrhid6/vantage/shared/models, which is the only copy. +type PendingSignup struct { + ID bson.ObjectID `bson:"_id,omitempty"` + PendingID string `bson:"pending_id"` + OrgName string `bson:"org_name"` + Email string `bson:"email"` + PasswordHash string `bson:"password_hash"` + TokenHash string `bson:"token_hash"` + CreatedAt time.Time `bson:"created_at"` + ExpiresAt time.Time `bson:"expires_at"` +} +``` + +- [ ] **Step 3: Delete the duplicated rules** + +```bash +cd c:/Work/Repos/vantage +rm -rf sitesvc/internal/provision +``` + +- [ ] **Step 4: Rewrite the store's provisioning paths** + +In `sitesvc/internal/store/store.go`: + +Replace the two local imports + +```go + "github.com/mrhid6/vantage/sitesvc/internal/models" + "github.com/mrhid6/vantage/sitesvc/internal/provision" +``` + +with + +```go + "github.com/mrhid6/vantage/shared/indexes" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "github.com/mrhid6/vantage/shared/provision" + "github.com/mrhid6/vantage/sitesvc/internal/models" +``` + +Replace the error variables so `errors.Is` keeps working for existing callers in `internal/api`: + +```go +var ( + ErrEmailTaken = provision.ErrEmailTaken + ErrBadToken = errors.New("verification link is invalid or has expired") + ErrNameRejected = provision.ErrNameRejected +) +``` + +Replace `EnsureIndexes` with: + +```go +func EnsureIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // users.email and orgs.slug are declared in the shared module so both + // services agree. Re-declaring at boot means sitesvc does not depend on the + // control plane having started first. + if err := indexes.EnsureCoreIndexes(ctx, database); err != nil { + return err + } + + _, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "token_hash", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + {Keys: bson.D{{Key: "email", Value: 1}}}, + { + Keys: bson.D{{Key: "expires_at", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(0), + }, + }) + if err != nil { + return fmt.Errorf("pending signup indexes: %w", err) + } + return nil +} +``` + +Delete the local `createOrg` and `rollbackOrg` functions entirely. + +Replace `Verify` with: + +```go +func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) { + var pending models.PendingSignup + err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{ + "token_hash": hashToken(rawToken), + "expires_at": bson.M{"$gt": time.Now().UTC()}, + }).Decode(&pending) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrBadToken + } + if err != nil { + return nil, err + } + + org, err := provision.CreateOrg(ctx, database, pending.OrgName) + if err != nil { + return nil, err + } + + // The password was hashed when the signup was recorded; only the hash + // survives to this point. + _, err = provision.CreateUserWithHash(ctx, database, org.OrgID, pending.Email, + pending.PasswordHash, sharedmodels.RoleOwner, "local") + if err != nil { + // Leaving an org behind would permanently occupy a slug nobody owns. + if rbErr := provision.RollbackOrg(ctx, database, org.OrgID); rbErr != nil { + log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr) + } + return nil, err + } + + return org, nil +} +``` + +`CreatePending` already calls `provision.BaseSlug` and `provision.BcryptCost`; those now resolve to the shared package with no line changes beyond the import swap. + +- [ ] **Step 5: Build and vet** + +```bash +cd c:/Work/Repos/vantage/sitesvc +go build ./... && go vet ./... +``` + +Expected: no output. If `internal/api` fails because `Verify` now returns a different `*models.Org`, update its import to `sharedmodels` there too. + +- [ ] **Step 6: Confirm the duplication is gone** + +```bash +cd c:/Work/Repos/vantage +grep -rn "func Slugify\|ReservedSlugs =\|BcryptCost =\|bson:\"org_id\"" sitesvc/ +``` + +Expected: no output. Any `provision.`-qualified *references* are fine; what must be gone are local *definitions*. + +- [ ] **Step 7: Commit** + +```bash +git add sitesvc/ +git commit -m "refactor(sitesvc): use shared models and provision + +Deletes internal/provision and the hand-mirrored Org and User structs. The +control plane and sitesvc now share one definition of both." +``` + +--- + +### Task 7: Docker and CI + +`replace => ../shared` cannot resolve when the build context is the module directory. Both Go images build from the repo root instead. + +**Files:** +- Modify: `server/Dockerfile`, `sitesvc/Dockerfile` +- Modify: `.gitea/workflows/server-deploy.yml` + +**Interfaces:** +- Consumes: the module layout from Tasks 1–6 +- Produces: images identical in content to today's, built from a different context + +- [ ] **Step 1: Rewrite the server Dockerfile build stage** + +Replace the build stage of `server/Dockerfile` with: + +```dockerfile +# Build stage +# +# Context is the repository root, not server/, because server depends on the +# shared module through a replace directive. +FROM golang:1.26 AS builder + +WORKDIR /src + +# Manifests first so the dependency layer caches independently of source edits. +COPY shared/go.mod shared/go.sum ./shared/ +COPY server/go.mod server/go.sum ./server/ +RUN cd server && go mod download + +COPY shared/ ./shared/ +COPY server/ ./server/ + +ARG VERSION=dev +RUN cd server && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd +``` + +Leave the runtime stage exactly as it is. + +- [ ] **Step 2: Rewrite the sitesvc Dockerfile build stage** + +Replace the build stage of `sitesvc/Dockerfile` with: + +```dockerfile +# Context is the repository root; sitesvc depends on the shared module. +FROM golang:1.26-alpine AS builder + +WORKDIR /src + +COPY shared/go.mod shared/go.sum ./shared/ +COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/ +RUN cd sitesvc && go mod download + +COPY shared/ ./shared/ +COPY sitesvc/ ./sitesvc/ + +RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd +``` + +Leave the runtime stage exactly as it is. + +- [ ] **Step 3: Build both images locally** + +```bash +cd c:/Work/Repos/vantage +docker build -f server/Dockerfile -t vantage-server:test . +docker build -f sitesvc/Dockerfile -t vantage-sitesvc:test . +``` + +Expected: both succeed, each ending with `naming to docker.io/library/vantage-...:test`. + +- [ ] **Step 4: Update the CI workflow** + +In `.gitea/workflows/server-deploy.yml`, for the `server` and `sitesvc` image build steps only: + +```yaml + context: . + file: server/Dockerfile +``` + +and + +```yaml + context: . + file: sitesvc/Dockerfile +``` + +Leave the `web` and `site` build steps untouched — they are Node images and do not use the shared module. + +- [ ] **Step 5: Confirm the agent pipeline is untouched** + +```bash +cd c:/Work/Repos/vantage +git diff --name-only HEAD -- .gitea/workflows/agent-release.yml agent/ +``` + +Expected: no output. If either appears, revert those changes — the agent is explicitly out of scope. + +- [ ] **Step 6: Commit** + +```bash +git add server/Dockerfile sitesvc/Dockerfile .gitea/workflows/server-deploy.yml +git commit -m "build: build Go images from the repo root for the shared module" +``` + +--- + +### Task 8: End-to-end verification + +No new code. This is the gate, and with no automated tests it is the **only** evidence the refactor worked. Every step must be performed and its output observed — not assumed. + +**Files:** none + +- [ ] **Step 1: Build everything, including the agent** + +```bash +cd c:/Work/Repos/vantage +(cd shared && go build ./... && go vet ./...) +(cd server && go build ./... && go vet ./...) +(cd sitesvc && go build ./... && go vet ./...) +(cd agent && go build ./... && go vet ./...) +``` + +Expected: no output from any of the eight commands. + +- [ ] **Step 2: Cross-compile the agent** + +```bash +cd c:/Work/Repos/vantage/agent +GOOS=linux GOARCH=amd64 go build -o /tmp/a1 ./cmd +GOOS=linux GOARCH=arm64 go build -o /tmp/a2 ./cmd +GOOS=windows GOARCH=amd64 go build -o /tmp/a3.exe ./cmd +``` + +Expected: all three succeed. This proves the workspace did not leak into the agent's build. + +- [ ] **Step 3: Boot the control plane against a scratch database** + +```bash +cd c:/Work/Repos/vantage/server +MONGO_URI=mongodb://localhost:27017 MONGO_DB=vantage_scratch \ + GRPC_HOST=localhost:9090 go run ./cmd +``` + +Expected in the logs: `connected to MongoDB`, no index error, no migration error, and the HTTP listener starting. + +Confirm the indexes were created — in `mongosh`: + +```javascript +use vantage_scratch +db.users.getIndexes() +db.orgs.getIndexes() +``` + +Expected: a unique index on `email` and a unique index on `slug` respectively. + +- [ ] **Step 4: Bootstrap through the control plane** + +With the server still running: + +```bash +curl -s localhost:8080/auth/bootstrap-status +curl -s -X POST localhost:8080/auth/bootstrap \ + -H 'Content-Type: application/json' \ + -d '{"org_name":"Acme Corp","email":"owner@example.com","password":"hunter2hunter2"}' +``` + +Expected: bootstrap succeeds. Then in `mongosh`: + +```javascript +db.orgs.findOne({}, {org_id: 1, name: 1, slug: 1}) +``` + +Expected: `slug: "acme-corp"`, a non-empty `org_id`, `name: "Acme Corp"`. + +This exercises `shared.CreateOrg` and `shared.CreateUser` through the control plane. + +- [ ] **Step 5: Confirm slug collision handling** + +```bash +curl -s -X POST localhost:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/c.txt +``` + +Then create two more organisations named `Acme Corp` — through the UI, or directly in `mongosh` by calling the server again if a bootstrap-only path is not available. Confirm: + +```javascript +db.orgs.find({}, {slug: 1}).sort({slug: 1}) +``` + +Expected: `acme-corp`, `acme-corp-2`, `acme-corp-3`. + +- [ ] **Step 6: The end-to-end agreement test** + +**This is the step that proves the refactor worked.** It confirms the two services still agree about the documents they share. + +Start sitesvc against the same scratch database: + +```bash +cd c:/Work/Repos/vantage/sitesvc +MONGO_URI=mongodb://localhost:27017/vantage_scratch \ + PUBLIC_URL=http://localhost:8082 \ + SITE_ORIGIN=http://localhost:3001 \ + SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=noreply@example.com \ + go run ./cmd +``` + +Use a local mail catcher for SMTP: `docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog` + +Then: + +1. `curl -s -X POST localhost:8082/api/signup -H 'Content-Type: application/json' -d '{"org_name":"Globex Ltd","email":"new@example.com","password":"hunter2hunter2"}'` +2. Open MailHog at `http://localhost:8025`, copy the verification link. +3. Open the verification link. +4. Log into the control plane with the new credentials: + +```bash +curl -s -X POST localhost:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"new@example.com","password":"hunter2hunter2"}' -c /tmp/c2.txt +curl -s localhost:8080/auth/me -b /tmp/c2.txt +``` + +Expected: login succeeds and `/auth/me` returns the org sitesvc created, with slug `globex-ltd`. + +**If login fails, the two services disagree about a document shape. The cause is in Task 5 or 6.** Do not proceed. + +- [ ] **Step 7: Confirm rollback still refuses** + +Create a throwaway `shared/cmd/rbcheck/main.go`: + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/mrhid6/vantage/shared/provision" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func main() { + c, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017")) + if err != nil { + panic(err) + } + err = provision.RollbackOrg(context.Background(), c.Database("vantage_scratch"), os.Args[1]) + fmt.Println("err =", err) +} +``` + +Run it with the `org_id` of an org that has a user: + +```bash +cd shared && go run ./cmd/rbcheck +``` + +Expected: `err = refusing to roll back organisation : it has 1 user(s)` + +Then run it with a manually inserted org that has no users. Expected: `err = `, and the org is gone. + +Delete the throwaway: `rm -rf shared/cmd/rbcheck` + +- [ ] **Step 8: Confirm the duplication is gone repo-wide** + +```bash +cd c:/Work/Repos/vantage +grep -rn "func Slugify" --include=*.go . +grep -rn "ReservedSlugs = map" --include=*.go . +grep -rn "bson:\"org_id\"" --include=*.go . +``` + +Expected: +- `func Slugify` — exactly one hit, in `shared/provision/slug.go` +- `ReservedSlugs = map` — exactly one hit, in `shared/provision/slug.go` +- `bson:"org_id"` — hits only in `shared/models/` and `server/internal/models/` for server-only documents. **No hit anywhere under `sitesvc/`.** + +- [ ] **Step 9: Drop the scratch database and commit** + +```javascript +use vantage_scratch +db.dropDatabase() +``` + +```bash +git add -A +git commit -m "chore: verify shared module extraction end to end" +``` + +--- + +## Rollout + +Single release, no database migration, no downtime beyond the normal restart. + +`server` and `sitesvc` images are built from one commit and deployed together. A version skew is harmless here because no document changed — but there is no reason to split it. + +```bash +cd /opt/vantage && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \ + docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans +``` + +Post-deploy checks: + +1. Control plane login works. +2. A signup through the marketing site completes and the new owner can log in. +3. Server logs show no index errors at boot. + +## What this unblocks + +Plan 0b (`instance-rename`) becomes a rename inside one module plus its two +consumers, rather than a rename across three independent copies of the same +structs. That is the reason this plan goes first. diff --git a/docs/superpowers/specs/2026-07-24-admin-backend-design.md b/docs/superpowers/specs/2026-07-24-admin-backend-design.md new file mode 100644 index 0000000..dcd3de2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-admin-backend-design.md @@ -0,0 +1,410 @@ +# Spec 3 — Admin Backend + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`) +Ships: with spec 4 (the site it serves). Blocks specs 4 and 5. + +## Context + +A fourth Go service, `admin/`, owning customers, instances, licences and +subscriptions. It is the only service that holds the signing key. + +Its data model separates two things the control plane deliberately does not know +about: + +- **Account** — a paying customer. Holds a Paddle customer, a billing email, and + one or more instances. +- **Instance** — one deployment. Cloud instances mirror a control-plane + `Instance` row; self-hosted instances exist only here, because the customer's + database is theirs and we cannot see it. + +## Goals + +1. Issue, store and re-issue licences, with full history. +2. Inject licences into cloud instances. +3. Serve both staff and customers, with the right things hidden from each. +4. Never be a runtime dependency of a Vantage instance. If admin is down, + every instance keeps working; only purchasing and renewals stop. + +## Non-goals + +- The UI. Spec 4. +- Paddle. Spec 5. This spec defines the `subscriptions` table and the issuance + functions that spec 5's webhooks call, and nothing more. +- Rebuilding billing management. Card changes, invoices and cancellation go to + Paddle's own customer portal. + +## Design + +### Module + +``` +admin/ +├── go.mod # replace => ../shared +├── cmd/main.go +└── internal/ + ├── api/ # gin handlers + ├── auth/ # staff, cloud-customer and local-customer sessions + ├── db/ # two connections: admin DB and control-plane DB + ├── models/ # admin-owned documents + ├── licensing/ # issuance, renewal, relink + ├── inject/ # control-plane writes + ├── mail/ # licence delivery + └── paddle/ # spec 5 lands here +``` + +Port `8083`. In `deploy/docker-compose.site.yml` only — like sitesvc, admin is +**excluded from the self-hosted deployment**. A self-hosted customer runs +instances, not the licensing authority. + +### Two database connections + +The service holds two: + +- `ADMIN_MONGO_URI` — its own database, `vantage_admin`. Sole owner. +- `CONTROL_MONGO_URI` — the control plane's database, used to write licence + fields onto cloud instance documents and to authenticate cloud customers. + +The control-plane connection uses the `Instance` and `User` structs from +`shared` (spec 0a). This is what makes direct writes safe: there is no admin-side +copy of the document shape to drift, which is the coupling hazard sitesvc used to +carry. + +Admin's control-plane access is **narrow by construction**: it reads +`instances` and `users`, and it writes exactly three fields on `instances`. The +Mongo credential it is given should be scoped to that where the deployment allows +it. It must never write to any other collection. + +### Data model + +```go +type Account struct { + ID bson.ObjectID + AccountID string // uuid + Name string + BillingEmail string + PaddleCustomerID string // empty until first checkout + Status string // active | suspended + CreatedAt time.Time +} + +type Instance struct { + ID bson.ObjectID + InstanceID string // for cloud: equals the control-plane instance_id + // for self-hosted: the UUID the customer pasted + AccountID string + Name string + Slug string // cloud only; the subdomain label + Deployment string // cloud | self_hosted + Tier string + Status string // awaiting_link | active | lapsed | cancelled + CurrentLicense string // licence ID + RelinkCount int // reset each term + CreatedAt time.Time +} + +type License struct { + ID bson.ObjectID + LicenseID string + InstanceID string + AccountID string + Tier string + Deployment string + Limits license.Limits // snapshot + Features []string // snapshot + IssuedAt time.Time + ExpiresAt time.Time + Blob string + SupersededBy string // licence ID, when replaced + IssuedBy string // staff user, "system", or "paddle:" + Reason string // new | renewal | tier_change | relink | manual +} + +type Subscription struct { + ID bson.ObjectID + SubscriptionID string + AccountID string + InstanceID string + PaddleSubscriptionID string + PaddlePriceID string + Tier string + Term string // monthly | annual + Status string // active | past_due | cancelled | awaiting_link + CurrentPeriodEnd time.Time +} + +type Plan struct { + Tier string + Name string + Deployment string + Limits license.Limits + Features []string + PaddleProductID string + PaddlePriceIDs map[string]string // "monthly" | "annual" + Active bool +} +``` + +Collections: `accounts`, `admin_instances`, `licenses`, `subscriptions`, +`plans`, `staff_users`, `customer_users`, `admin_audit`. + +Unique indexes: `accounts.account_id`, `admin_instances.instance_id`, +`licenses.license_id`, `subscriptions.paddle_subscription_id`, `plans.tier`, +`staff_users.email`, `customer_users.email`. + +`admin_instances.instance_id` unique is load-bearing: it is what stops the same +self-hosted UUID being linked to two accounts. + +**Licences are append-only.** A renewal writes a new row and sets +`SupersededBy` on the old one. Nothing is ever edited or deleted. When a support +question arrives about why a customer's instance stopped working on a given +date, the answer is in the table. + +`plans` holds tier contents so they change without a deploy, seeded from the +table in spec 1. Every issued licence snapshots the plan, so editing a plan never +changes an existing licence — the same rule as `workflow_runs.steps_snapshot`. + +### Issuance + +```go +func Issue(ctx, instanceID, tier, term, reason, issuedBy string) (*models.License, error) +``` + +1. Load the instance and its account. +2. Load the plan for `tier`; refuse if `plan.Deployment != instance.Deployment`. + **This is the check that makes Free cloud-only** — Free's plan is + `deployment: cloud`, so it can never be issued to a self-hosted instance. +3. Build the payload with the instance's UUID bound in, `ExpiresAt` from the term + plus a **3-day grace** so a renewal webhook arriving slightly late does not + create a gap. +4. Sign with `LICENSE_SIGNING_KEY`. +5. Insert the licence row; set `SupersededBy` on the previous one; update + `instance.CurrentLicense` and `instance.Tier`. +6. If cloud, inject. If self-hosted, email the blob and make it downloadable. +7. Write an `admin_audit` entry. + +Steps 5 and 6 are not transactional. Order matters: **record first, deliver +second.** A licence recorded but not delivered is recoverable — the customer +downloads it. A licence delivered but not recorded is a support mystery. + +### Free tier rule + +One Free instance per account, enforced in `Issue`: refuse a second Free instance +for an account that already has one that is not `cancelled`. Additional +instances must be paid. + +### Injection + +```go +func InjectCloud(ctx, instanceID string, lic *models.License) error +``` + +Writes `license_blob`, `license_tier`, `license_expiry` onto the control-plane +`instances` document via a single `UpdateOne`. Idempotent, retryable, and safe to +re-run. + +Retries three times with backoff; on final failure the licence stays recorded and +`instance.Status` is set to `active` regardless, with the failure logged and +surfaced as a staff alert. A **reconciliation job runs every 15 minutes**, +comparing each cloud instance's `CurrentLicense` against the blob actually stored +in the control plane, and re-injecting on mismatch. That job, not the webhook, is +what guarantees eventual consistency. + +The control-plane instance caches licence state for 60 seconds (spec 2), so an +injection takes effect within a minute without a restart. + +### Self-hosted linking + +The flow, end to end: + +``` +Customer runs /setup on their own install → instance UUID generated and shown +Customer buys Self Hosted in the admin site → subscription created, + status awaiting_link +Customer pastes the UUID into the admin site → admin_instances row created, + status active +Admin issues the licence with that UUID bound in +Customer downloads the .lic file or copies the blob +Customer pastes it into /settings/license on their install +``` + +Validation on link: the UUID must parse as a UUID, must not already exist in +`admin_instances`, and must not collide with a cloud instance ID. A duplicate +returns "That instance ID is already linked to an account" without revealing +which — it is a small enumeration surface but there is no reason to leave it +open. + +### Relink + +A rebuilt server has a new UUID. `POST /api/instances/:id/relink` with the new +UUID: + +- Allowed **3 times per term**, `RelinkCount` reset on renewal. +- Updates `admin_instances.instance_id`, issues a replacement licence for the + **remaining term** with `reason: relink`, supersedes the old one. +- The old licence is not revoked — it cannot be, offline verification has no + revocation. It simply no longer matches any UUID the customer controls, and its + binding stops it being useful on a different machine anyway. +- Beyond 3, the endpoint returns a message directing the customer to support, and + staff can relink without limit. + +`RelinkCount` is the abuse signal, not the abuse prevention. Its real job is to +put a human in front of the fourth attempt. + +### Authentication + +Three identities, three paths, one session store (Redis, `admin_session` +cookie, 24h). + +**Staff** — `staff_users`, local email plus bcrypt. Full access. Created by CLI +only; there is no staff signup. + +**Cloud customers** — authenticate against the control plane's `users` +collection with the credentials they already use. Admin looks the user up +by email, checks bcrypt, resolves their control-plane instance, then resolves the +account that owns it. + +Two consequences, stated plainly because they are real: + +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 users with control-plane role `owner` may sign in to the admin site. + `admin` and `member` are refused. Billing is an owner concern. + +Mitigations: rate-limit to 5 attempts per email per 15 minutes and 20 per IP per +hour; log every attempt to `admin_audit`; return an identical error for unknown +email and wrong password. + +**Self-hosted customers** — `customer_users`, local email plus bcrypt at cost 12, +created during purchase, scoped to one account. Email verification reuses the +pattern sitesvc already proved: 32 random bytes, only the SHA-256 hash stored, +24-hour expiry, TTL index. + +A single email address could in principle be both a cloud user and a +self-hosted customer user. `customer_users` is checked first; if it matches, that +identity wins. Documented so the behaviour is chosen rather than emergent. + +### API + +Staff: + +``` +GET /api/staff/accounts list, search +POST /api/staff/accounts +GET /api/staff/accounts/:id +GET /api/staff/instances filter by account, deployment, status, expiry +POST /api/staff/instances/:id/issue manual issue or reissue +POST /api/staff/instances/:id/relink no limit +GET /api/staff/licenses full history, filterable +GET /api/staff/plans +PUT /api/staff/plans/:tier +GET /api/staff/audit +GET /api/staff/health/injection reconciliation status and failures +``` + +Customer: + +``` +GET /api/account own account and instances +POST /api/instances/link self-hosted UUID link +POST /api/instances/:id/relink rate-limited +GET /api/instances/:id/license current licence metadata +GET /api/instances/:id/license/download .lic file +GET /api/subscriptions status, next renewal +POST /api/billing/portal Paddle portal redirect (spec 5) +``` + +Every customer handler resolves the account from the session and scopes by it. +The scoping is enforced by a helper every handler calls, not by each handler +remembering — the same deny-by-default reasoning as spec 2's middleware. + +### Configuration + +| Variable | Required | Notes | +|---|---|---| +| `ADMIN_MONGO_URI` | yes | admin's own database; name read from the URI path, refused if absent | +| `CONTROL_MONGO_URI` | yes | control-plane database, for injection and cloud auth | +| `REDIS_ADDR` | yes | sessions | +| `LICENSE_SIGNING_KEY` | yes | ed25519 private key hex. **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy | +| `PUBLIC_URL` | yes | for verification and licence links | +| `SMTP_*` | yes | licence delivery | +| `ADMIN_ORIGIN` | yes | CORS allow-list | +| `TRUST_PROXY` | no | only behind a proxy that overwrites `X-Forwarded-For` | +| Paddle variables | spec 5 | | + +### Backfill + +Licences issued by `lkctl` during the spec 1–2 period exist only as blobs. +A one-shot `admin backfill --from=blobs.json` parses each with +`license.Parse`, creates the account, instance and licence rows, and marks them +`reason: manual`. Run once when admin goes live. + +## Testing + +**Issuance:** + +1. `Issue` produces a licence that `license.Verify` accepts for that instance. +2. Deployment mismatch (Free plan, self-hosted instance) is refused. +3. A second Free instance for the same account is refused; a third paid one is + allowed. +4. Renewal supersedes the previous licence and leaves it in the table. +5. The issued licence snapshots the plan; editing the plan afterwards does not + change the issued licence. +6. Grace period: `ExpiresAt` is term end plus 3 days. + +**Injection:** + +7. `InjectCloud` writes all three fields; the control plane then reports `valid`. +8. Injection is idempotent across two calls. +9. Injection failure leaves the licence recorded and flags the instance. +10. The reconciliation job detects a control-plane blob that does not match + `CurrentLicense` and re-injects. + +**Linking and relink:** + +11. Linking an unknown UUID succeeds; linking one already linked is refused. +12. Relink issues a licence for the *remaining* term, not a fresh full term. +13. The fourth relink in a term is refused for a customer and allowed for staff. +14. `RelinkCount` resets on renewal. + +**Auth:** + +15. Cloud owner signs in with control-plane credentials; `admin` and `member` + roles are refused. +16. Unknown email and wrong password return identical errors and timing is not a + meaningful oracle. +17. Rate limits trigger at the documented thresholds. +18. Self-hosted customer cannot sign in before verifying their email. +19. A customer requesting another account's instance gets `404`, not `403` — + no existence disclosure. + +**Scoping:** + +20. Every customer endpoint, called with a session for account A against a + resource of account B, returns `404`. Written as a table-driven test over the + route list so a new endpoint that forgets to scope fails the build. + +## Verification before merge + +1. Full suite green, including the scoping table test (test 20). +2. End to end, cloud: create account → create instance → issue Professional → + confirm the control-plane instance reports `valid` within 60 seconds with no + restart. +3. End to end, self-hosted: run `/setup` on a scratch install, copy the UUID, + link it, issue, download, paste, confirm `valid`. +4. Confirm admin's control-plane credential cannot write to `servers`, `keys` or + any collection other than `instances`. +5. Kill the admin service and confirm every Vantage instance keeps working + entirely normally. + +## Risks + +| Risk | Mitigation | +|---|---| +| Admin becomes a runtime dependency | Verification step 5; instances verify offline and never call admin | +| Signing key exposure | Single service, single variable, never in an image; rotation path from spec 1 | +| Cloud password now unlocks billing | Owner-only, rate-limited, audited, and stated in the release notes | +| Injection silently fails | Reconciliation every 15 minutes plus a staff health endpoint | +| Admin writes outside its remit in the control plane | Narrow code path; scoped Mongo credential; reviewed on every change | +| Self-hosted UUID squatted by another account | Unique index plus a non-disclosing error | diff --git a/docs/superpowers/specs/2026-07-24-admin-site-design.md b/docs/superpowers/specs/2026-07-24-admin-site-design.md new file mode 100644 index 0000000..1a11018 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-admin-site-design.md @@ -0,0 +1,203 @@ +# Spec 4 — Admin Site + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 3 (`admin-backend`) +Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API +is stable. + +## Context + +A fifth Next.js app, `adminsite/`, serving two audiences from one codebase: + +- **Staff** — internal operators. Accounts, instances, licence history, plan + editing, injection health, audit. +- **Customers** — their own account, instances, licences and subscription state. + +They share auth plumbing and a component library but almost no screens. The +split is by route group, so a customer route can never accidentally render a +staff view. + +## Goals + +1. A customer can buy, link a self-hosted instance, download a licence, and see + when it expires — without contacting anyone. +2. Staff can answer "why did this customer's instance stop working" in one screen. +3. Nothing about the marketing site or the control-plane UI changes. + +## Non-goals + +- Rebuilding billing management. Card details, invoices, payment methods and + cancellation all deep-link into Paddle's customer portal. +- Server management. This is not a second control plane; there is exactly one + link out to the instance and no data about servers, keys or workflows. +- Public signup for cloud. That stays on the marketing site (moving to admin's + backend in spec 5, but the *form* stays where customers already find it). + +## Design + +### App + +Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind +3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`, +published as `3002`. In `docker-compose.site.yml` only. + +`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be +**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting +this wrong is the single most common deployment failure in this repo's history — +`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app +renders an explicit "not connected" state rather than failing silently. + +``` +adminsite/ +├── app/ +│ ├── login/ +│ ├── signup/ # self-hosted customer account creation +│ ├── verify/ +│ ├── (customer)/ +│ │ ├── page.tsx # account overview +│ │ ├── instances/[id]/ +│ │ ├── instances/link/ +│ │ ├── billing/ +│ │ └── layout.tsx # customer nav, account guard +│ └── (staff)/staff/ +│ ├── page.tsx # operations dashboard +│ ├── accounts/[id]/ +│ ├── instances/[id]/ +│ ├── licenses/ +│ ├── plans/ +│ └── layout.tsx # staff nav, staff guard +├── components/ +└── lib/ +``` + +Route-group layouts do the guarding. A customer session hitting `/staff/*` gets +redirected, not a 403 page — there is nothing to tell them about. + +### Customer screens + +**Overview** — the account, its instances as cards. Each card: name, cloud or +self-hosted, tier, licence state, expiry with days remaining, and a link either +to the instance's subdomain (cloud) or to its licence page (self-hosted). + +Licence state is colour-coded and blunt: green valid, amber under 14 days, red +expired. An expired card says what still works — "servers and monitors are still +running; changes are disabled" — because that is the first thing a worried +customer wants to know. + +**Instance detail** — tier, limits, features, subscription status, next renewal +date. For self-hosted: the linked UUID, a **Download licence** button, the blob +in a copy-to-clipboard box, and step-by-step paste instructions with the target +route named (`Settings → Licence` on their own install). A **Relink** action +showing the remaining allowance ("2 of 3 relinks remaining this term"). + +**Link an instance** — the self-hosted activation screen. Explains where to find +the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the +paste, validates the format client-side, and on success issues the licence and +lands the customer directly on the download. + +The whole flow — buy, link, download, paste — should be completable without +reading documentation. That is the bar for this screen. + +**Billing** — subscription list with status and renewal date, plus a button to +Paddle's portal. Deliberately thin. + +### Staff screens + +**Dashboard** — the operational answers, not vanity metrics: licences expiring +in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more +than 48 hours, and **failed injections** from the reconciliation job. Each row +links straight to the thing that needs doing. + +**Accounts** — searchable by name, email, Paddle customer ID and instance UUID. +Searching by UUID matters: a support email arrives containing a UUID and nothing +else. + +**Account detail** — instances, subscriptions, customer users, audit trail. + +**Instance detail** — everything about one instance, with the **full licence +history as a timeline**: issued, superseded, renewed, relinked, each with a +timestamp, reason and who did it. This is the screen that answers "why did this +stop working on the 14th". Actions: issue, reissue, relink without limit, and a +live view of the control-plane injection state for cloud instances. + +**Licences** — global history, filterable by tier, deployment, expiry window and +issuance reason. + +**Plans** — edit limits and features per tier. Two guard rails, because this +screen changes what every future customer gets: + +- A confirmation step naming exactly what changes and stating that existing + licences are unaffected until reissued. +- The deployment field is not editable. Moving Free to `self_hosted` would break + the cloud-only rule that spec 1 leans on; changing it is a code review, not a + form field. + +**Audit** — every mutating action, filterable. + +### Design language + +Visually distinct from `web/`. Staff regularly have both open, and a moment of +"which app am I in" before clicking Reissue is worth designing out. Different +accent colour and a persistent environment badge in the header (sandbox or +production, from a build-time flag) — clicking Issue against the wrong Paddle +environment should be hard. + +Shared component patterns with `web/` where they exist; this is not a reason to +invent a second design system. + +### Error and empty states + +- Backend unreachable: a page-level "not connected" state naming + `ADMIN_API_URL`, matching the pattern the marketing site already uses. +- No instances yet: a customer-facing explanation of the two paths — buy cloud, + or buy self-hosted and link. +- `awaiting_link`: a prominent prompt on the overview, since a customer who has + paid and not linked is a customer who has paid for nothing yet. +- Licence download failure: show the blob inline as a fallback so the customer is + never blocked by a file download. + +## Testing + +Component and integration tests with mocked API responses. The repo has no +frontend test setup today; this is where one starts, scoped to the flows that +lose money or leak data when broken. + +1. Customer session on `/staff/*` redirects; staff session reaches it. +2. Instance card renders correctly for each licence state, including expired, + and the expired copy names what still works. +3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught + client-side; already-linked UUID surfaces the backend's message. +4. Relink shows the remaining allowance and disables at zero with the support + message. +5. Licence download failure falls back to the inline blob. +6. Not-connected state renders when the API is unreachable. +7. Staff dashboard renders each alert category and links to the right resource. +8. Plan edit requires confirmation and shows the "existing licences unaffected" + wording. +9. Instance search by UUID returns the instance. +10. Licence history timeline renders every reason type in order. + +## Verification before merge + +1. Test suite green. +2. Full manual pass, self-hosted purchase to working licence, using only the UI + and no documentation — timed, and if it takes more than five minutes the flow + needs work. +3. Full manual pass, cloud: buy, confirm the licence appears in the control plane + within a minute, confirm the instance's own settings page agrees. +4. Staff pass: find an account by instance UUID, read its licence history, + reissue, confirm the control plane picks it up. +5. Responsive check at mobile width — a customer hit by an expiry email will open + this on a phone. +6. `docker build` from the repo root succeeds and the image runs with + `ADMIN_API_URL` baked in. + +## Risks + +| Risk | Mitigation | +|---|---| +| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun | +| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions | +| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification | +| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this | diff --git a/docs/superpowers/specs/2026-07-24-instance-licensing-design.md b/docs/superpowers/specs/2026-07-24-instance-licensing-design.md new file mode 100644 index 0000000..6a8e56b --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-instance-licensing-design.md @@ -0,0 +1,353 @@ +# Spec 2 — Instance Licensing and Enforcement + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`) +Ships: independently, with licenses issued by hand via `lkctl`. No admin site +needed. + +## Context + +Spec 1 defines what a license is. This spec makes the control plane hold one, +act on it, and let a self-hosted operator paste one in. + +The guiding rule: **an expired license must never break a running fleet.** Agents +keep their keys, monitors keep watching, alerts keep firing. What stops is +growth and change. A customer whose card fails should be inconvenienced, not +paged at 3am because their monitoring went dark when Vantage decided to sulk. + +## Goals + +1. A license lives on the instance document and is verified on read. +2. Enforcement is deny-by-default: a new mutating route is gated because of where + it is mounted, not because someone remembered. +3. Degraded mode is obvious in the UI and reversible by pasting a valid license. +4. Self-hosted operators get an instance UUID they can hand to the admin site. + +## Non-goals + +- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3). +- Any outbound network call. Verification is offline, permanently. +- Per-user or per-role licensing. The unit is the instance. + +## Design + +### Instance identity + +Every install already has an `Instance` document with an `InstanceID` UUID. For +cloud instances this is created by signup; for self-hosted it is created by +`/setup`. + +Change to `/setup`: after bootstrapping the first instance and its owner, the +setup page **displays the instance UUID** with a copy button and the text that +it is needed to activate a license. It is also shown permanently on +`/settings/license`. + +No new identifier is invented. The instance UUID is the licensing identity. + +### Storage + +`shared/models.Instance` gains: + +```go +LicenseBlob string `bson:"license_blob,omitempty" json:"-"` +LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"` +LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"` +``` + +The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised +cache for listing and for the admin site's queries, rewritten from the verified +payload every time a blob is accepted. Nothing reads them for enforcement. + +`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense — +it is signed public data — but there is no reason to spray it through API +responses. + +### Runtime state + +```go +type State struct { + Status license.State // valid | expired | invalid + Reason string + Tier string + ExpiresAt *time.Time + Limits license.Limits + Features map[string]bool +} +``` + +Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds +alongside the existing instance cache and invalidated immediately when a blob is +stored. + +Three inputs, in precedence order: + +1. `Instance.LicenseBlob`. +2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no + stored blob**. This lets an automated self-hosted deployment ship a license + without a human pasting one. A blob stored through the UI always wins + afterwards, so an operator is never locked out by a stale environment value. +3. Neither → `Status: invalid`, `Reason: no_license`. + +The verifier is called with `InstanceID` from the instance document and +`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure, +`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters: +an operator who removes the variable gets the stricter mode, not the looser one. + +`invalid` and `expired` degrade identically. They differ only in the message. + +### Enforcement + +Three layers, deliberately separate because they answer different questions. + +**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on +the `/api` group, applying to every request whose method is not `GET` or `HEAD`. + +```go +api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense()) +``` + +Non-`valid` → `403 {"error":"license_required","state":"expired","reason":"..."}`. + +Mounting at the group means **a route added tomorrow is gated by default**. That +is the whole point of putting it here rather than on individual handlers. + +Explicit exemptions, allow-listed by path because they must work in degraded +mode: + +| Route | Why | +|---|---| +| `POST /api/license` | Pasting a valid license is how you recover | +| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity | +| `DELETE` on any resource | Deleting is how you get back under a limit | +| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled | + +The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10 +servers must be able to remove 7 of them. Blocking deletes would trap them. + +**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need +it: + +- `console` → `POST /api/console/connect`, `GET /api/console/tunnel` +- `oidc` → `GET,PUT /api/instance/oidc` + +Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`. + +OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated +and outside `/api`. They check the feature directly and, if unavailable, redirect +to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC +sessions are not terminated** — losing the feature stops new SSO logins, it does +not evict people mid-session. + +**Layer 3 — limits.** Enforced in the service layer, because a limit needs a +count that middleware does not have: + +| Limit | Checked in | +|---|---| +| `max_servers` | `services.CreateServer` / `POST /api/servers/new` | +| `max_secret_groups` | `services.CreateSecretGroup` | +| `max_channels` | `services.CreateChannel` | + +`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`. + +Counts are of live rows: revoked assignments and deleted servers do not count. + +**Over-limit instances are never truncated.** A Professional instance with 20 +servers that lapses to Free keeps all 20 running; it simply cannot add a 21st. +Deleting resources is always permitted. Silently disabling a customer's servers +because their card expired is not a behaviour this system will have. + +### Background work in degraded mode + +This is where "read-only" needs to be specific, because these paths do not go +through gin at all. + +| Subsystem | Degraded behaviour | +|---|---| +| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. | +| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). | +| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. | +| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. | +| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. | +| Inventory, heartbeat, update reporting | Unaffected. | +| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. | +| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. | +| Log retention sweep, offline sweep | Unaffected. | + +Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It +is the single most important line in this spec: **billing state must not take +away a customer's ability to know their infrastructure is on fire.** + +### API + +``` +GET /api/license any authenticated user +POST /api/license owner only +``` + +`GET` returns: + +```json +{ + "instance_id": "…", + "state": "valid", + "reason": "", + "tier": "professional", + "expires_at": "2027-07-24T00:00:00Z", + "days_remaining": 365, + "limits": { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 }, + "features": { "console": true, "oidc": true }, + "usage": { "servers": 12, "secret_groups": 4, "channels": 2 }, + "source": "stored" +} +``` + +`usage` is included so the UI can render "12 of 3 servers" honestly when an +instance is over its limit, rather than pretending. + +`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and +deployment mode, and on success stores the blob, refreshes the cache, and writes +an audit event. On failure it returns `400` with the specific reason: + +| Reason | Message | +|---|---| +| `bad_signature` | This licence key is not valid. Check it was copied in full. | +| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. | +| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is ``. | +| `expired` | This licence expired on ``. | + +An **expired** blob is still stored if it is otherwise valid, so the UI can show +what expired and when. An **invalid** blob is rejected and the previous one kept. + +Rate-limited to 10 attempts per instance per hour. There is no oracle here worth +protecting, but an unbounded verify endpoint is an unbounded CPU endpoint. + +### Frontend + +`useLicense()` hook over `GET /api/license`, cached by TanStack Query and +invalidated after a successful paste. + +- **Banner, persistent, top of every page** when `state != valid`: + - `expired` — "Your Vantage licence expired on ``. Your servers and + monitors are still running, but changes are disabled until it is renewed." + with a link to the admin site. + - `invalid` / `no_license` — "This instance has no valid licence. Add one in + Settings → Licence." +- **Warning banner** in the final 14 days of a valid term, dismissible per + session. +- **Gated features render disabled with an upgrade tooltip, not hidden.** A + customer cannot buy what they cannot see, and a feature that vanishes reads as + a bug. +- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3 + servers used" with the create button disabled at the cap. +- `/settings/license`: current state, tier, expiry, limits with live usage, the + instance UUID with a copy button, and a textarea plus file upload for a new + blob. Owner-only; other roles see the state read-only. + +### Grandfathering existing tenants + +Migration `0005_grandfather_licenses`, cloud only, guarded on +`VANTAGE_DEPLOYMENT == "cloud"`: + +For every instance with no `license_blob`, issue a Professional license expiring +**one year** from the migration date and store it. + +The migration cannot sign — the server has no private key and, per spec 1, no +signing code. So the blobs are **generated ahead of time with `lkctl`** and +supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of +instance ID to blob. The migration stores what it is given, verifies each blob +against its instance before storing, and logs any instance it had no blob for. + +Clumsy, and correct. The alternative is putting a signing key in the control +plane, which is the thing this design most wants to avoid. + +Self-hosted installs are not grandfathered. On upgrade they land in `no_license` +and read-only until an operator pastes a key — which is the intended behaviour +for a paid product, and is why the release notes must lead with it. + +## Testing + +**Unit, no database:** + +1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`; + environment used when no blob; neither → `no_license`. +2. Feature map construction from the payload's `Features` slice. +3. Limit comparison with `-1`, with zero, and with a count exactly at the cap. + +**Middleware, with a stub state:** + +4. `GET` passes in every state. +5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when + `invalid` — except `DELETE`, which passes in all states. +6. `POST /api/license` passes when `expired` (the recovery path). +7. `POST /api/servers/:id/apply-updates` passes when `expired`. +8. `RequireFeature("console")` passes with the feature, `403`s without it. +9. **Coverage test:** enumerate every registered route and assert that every + non-`GET` route is either behind `RequireActiveLicense` or on the exemption + allow-list. This test is what stops layer 1 rotting as routes are added. + +**Service layer, against MongoDB:** + +10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds. +11. Over-limit instance can still `DELETE` a server, and can create again once + back under the cap. +12. Deleted and revoked rows do not count toward limits. + +**Degraded background behaviour:** + +13. Monitor scheduler executes checks for an instance with an expired license. +14. An incident opened during degraded mode still dispatches notifications. +15. `SyncKeys` for an expired instance returns the same key set as before expiry. +16. A new agent registering against an over-limit instance is refused; an + existing agent re-registers successfully. +17. A workflow run in flight when the license expires completes its remaining + steps. + +**API:** + +18. `POST /api/license` with a valid blob stores it and flips state to `valid`. +19. Each rejection reason returns its own message and leaves the stored blob + untouched. +20. An expired-but-well-formed blob is stored and reported as `expired`. +21. Non-owner `POST` → `403`. + +**Migration:** + +22. `0005` stores and verifies supplied blobs, skips instances that already have + one, logs instances with no blob supplied, and is a no-op when + `VANTAGE_DEPLOYMENT != "cloud"`. + +## Verification before merge + +1. Full test suite green, including the route-coverage test (test 9). +2. Manual pass on a scratch instance: issue a Professional license with `lkctl`, + paste it, confirm everything works. Issue one expiring in 60 seconds, wait, + confirm the banner appears, mutations `403`, **monitors keep firing**, and + pasting a fresh license restores normal operation without a restart. +3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC + are visibly disabled with upgrade tooltips, and that a 4th server is refused + with a clear message. +4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install + with `deployment_mismatch`. +5. Confirm a license issued for another instance is rejected with + `instance_mismatch` and the message shows the correct local UUID. + +## Rollout + +1. Generate grandfather blobs with `lkctl` for every existing cloud instance. +2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs. +3. Verify every cloud instance reports `valid`, Professional, one year out. +4. Unset the variable on the next deploy — it is single-use. +5. Release notes for self-hosted must state plainly that upgrading requires a + licence key, and how to get one. + +## Risks + +| Risk | Mitigation | +|---|---| +| A mutating route added later without a gate | Route-coverage test (test 9) fails the build | +| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate | +| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch | +| Over-limit customer trapped | Deletes always allowed; existing resources never truncated | +| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page | +| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state | diff --git a/docs/superpowers/specs/2026-07-24-instance-rename-design.md b/docs/superpowers/specs/2026-07-24-instance-rename-design.md new file mode 100644 index 0000000..92a86d1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-instance-rename-design.md @@ -0,0 +1,240 @@ +# Spec 0b — Org to Instance Rename + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a (`shared-module`) +Ships: independently, before any licensing code + +## Context + +The licensing model separates two concepts that the codebase currently conflates +under one word: + +- **Account** — a paying customer. Lives only in the admin control plane + (spec 3). The control plane never learns about it. +- **Instance** — one deployment of Vantage: its own subdomain, its own users, + its own servers, keys, workflows, monitors and secrets. One license attaches + to one instance. + +Today's control-plane `Org` **is** an Instance. An Account may hold several, +some cloud and some self-hosted, and the self-hosted ones have no row in the +cloud database at all. + +Keeping the name `Org` would leave the control plane using a word that means +something different in the admin site, in Paddle, and in every support +conversation. This spec renames it everywhere, including on disk. + +This is the highest-risk change in the programme: `org_id` is the tenant +isolation key on every document in every collection. It is done alone, before +anything else, so that nothing else is in flight when it deploys. + +## Goals + +1. `Instance` is the only word for a tenant, in code, API, UI and database. +2. No document is lost and no tenant scoping is weakened. +3. The migration is reversible. + +## Non-goals + +- Any behaviour change. Same routes' semantics, same permissions, same data. +- Introducing Accounts. The control plane never gets them. +- Touching the agent. It talks gRPC and has no concept of a tenant. + +## Design + +### Naming map + +| Today | After | +|---|---| +| collection `orgs` | `instances` | +| collection `org_oidc` | `instance_oidc` | +| field `org_id` (all collections) | `instance_id` | +| `models.Org` | `models.Instance` | +| `Org.OrgID` | `Instance.InstanceID` | +| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` | +| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … | +| `services/org_oidc.go` | `services/instance_oidc.go` | +| `auth/orghost.go` | `auth/instancehost.go` | +| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` | +| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` | +| session field `org_id` | `instance_id` | +| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` | +| UI copy "Organisation" | "Instance" | + +Reserved slugs gain no new entries here, but note `admin` is already reserved, +which the admin site relies on later. + +### Collections carrying `org_id` + +All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`, +`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`, +`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`, +`audit_logs`, plus `orgs` itself. `migrations` does not carry one. + +`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes +`instance_name` for consistency; it is sitesvc-private so this is free. + +The migration must derive this list from a constant in code, not from a +hand-written list in a runbook, so that a collection added between design and +deploy is not silently missed: + +```go +var scopedCollections = []string{ /* the list above */ } +``` + +A boot-time assertion (spec 2 onwards) checks that no collection outside this +list contains an `org_id` field. Cheap insurance against a future collection +being added without being renamed. + +### Migration `0004_org_to_instance` + +Recorded in `migrations` like the existing three. Runs after +`0003_missed_org_scopes`. + +**The migration only renames. It never deletes and never drops.** A bad deploy +is recovered by running the inverse rename, not by restoring a backup. + +Steps, in order: + +1. **Guard.** If collection `instances` already exists and `orgs` does not, the + migration has already run against this database by an earlier binary; record + the marker and return. Idempotency matters because the marker write and the + data work are not in one transaction. +2. **Rename collections.** `orgs` → `instances`, `org_oidc` → `instance_oidc`, + via `adminCommand{renameCollection}`. Fails loudly if the target exists. +3. **Rename the field.** For each collection in `scopedCollections`: + `UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`. + Record `matched` and `modified` per collection in the log. +4. **Verify.** For each collection, assert + `CountDocuments({org_id: {$exists: true}}) == 0` and + `CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch + aborts before the marker is written, leaving the migration to retry. +5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec: + unique `settings.instance_id`, the ESO token-hash index, and any compound + scoping indexes. Unique `instances.slug` and `users.email` are unaffected by + the field rename but are re-declared idempotently. +6. **Write the marker.** + +Steps 2–4 are not atomic across collections. Mongo multi-document transactions +would require a replica set, which is not guaranteed for self-hosted installs. +Instead the migration is written to be **safely re-runnable**: `$rename` on a +document that has already been renamed matches nothing, and the collection +rename is guarded in step 1. + +Rollback, if ever needed, is the same code with the rename reversed, shipped as +a one-shot command rather than a migration — deliberately manual, because the +only reason to run it is a decision to revert the release. + +### Version skew + +`sitesvc` and `server` write the same documents. A skew where one writes +`org_id` and the other reads `instance_id` creates tenants that are invisible to +the application — the exact failure `CLAUDE.md` warns about. + +After spec 0a both read the shape from `shared`, so the skew window is a +deployment-ordering problem rather than a code-drift problem: + +- Both images are built from the same commit and deployed together. +- The migration runs from the `server` container at boot, as the existing three + do. +- `sitesvc` at boot asserts that collection `instances` exists and refuses to + start otherwise, with the message + `instances collection not found; deploy the control plane first`. Failing to + start is strictly better than provisioning into a collection nobody reads. + +The self-hosted deployment runs no sitesvc, so it sees only the server change. + +### API and frontend + +REST route renames are **breaking**, but every consumer is first-party (`web/`) +and ships in the same release. No compatibility aliases — a permanent dual path +in the tenant-scoping layer is worse than a coordinated release. + +`web/` changes: the API client's paths, the `useMe` shape, all UI copy from +"Organisation" to "Instance", and the settings route `/settings/org` → +`/settings/instance`. + +`site/` marketing copy changes where it says "organisation" about a tenant. Where +it means the customer, it becomes "account" — that word now has a specific +meaning and the marketing site is the first place a customer meets it. + +## Testing + +**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a. + +This is the change where that costs the most: it moves the tenant isolation key +across 17 collections, and a mistake orphans a customer's entire fleet rather +than breaking a build. The compensating controls are therefore not optional, and +the implementation plan makes each a mandatory step: + +1. **Dry run against a restored copy** before the code is even committed — + migrate a `mongorestore`d duplicate of production and read the per-collection + rename counts. +2. **Idempotency by hand** — run the dry run twice; the second must complete + with no error and nothing left to rename. +3. **Interrupted-run recovery by hand** — rename `orgs` manually, then run the + migration; it must complete and leave every document carrying `instance_id`. +4. **Count comparison against a production snapshot** — record every + collection's document count before and after; any difference stops the + release. +5. **Per-tenant isolation comparison** — for three real tenants, count rows in + `servers`, `keys`, `workflows`, `monitors`, `secrets` and `audit_logs` by + `org_id` before and by `instance_id` after. Identical, or the release stops. + This is the check that proves tenant isolation survived. +6. **Stale-field sweep** — assert no collection anywhere still holds an + `org_id`. +7. **Rollback rehearsal** — migrate a third copy, run `rename-rollback`, confirm + the counts return to baseline and the pre-release binary boots against it. + Deploying without having done this is not permitted. +8. **Boot guard, both directions** — sitesvc must refuse an unmigrated database + and start normally against a migrated one. + +`AssertNoScopedCollectionMissed` runs at every boot and is fatal. With no test +suite it is the standing protection against a future collection being added +without being added to `ScopedCollections`. + +## Verification before merge + +Run against a **restored production snapshot**, not a synthetic database: + +1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before. +2. Run the migration. +3. Assert every count is identical afterwards. +4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`. +5. Pick three real tenants; run the same scoped query before (by `org_id`) and + after (by `instance_id`) and confirm identical result sets. This is the test + that proves tenant isolation survived. +6. Boot the server against the migrated snapshot; log in as a real user; confirm + servers, keys, workflows, monitors and secrets all list correctly. +7. Boot sitesvc against the migrated snapshot; complete a signup end to end. +8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start + with the expected message. + +## Rollout + +1. Take a database backup. Not optional — this is the one change where the + inverse rename is the recovery path and the backup is the second. +2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together. +3. Server boots, migration runs, marker recorded. +4. Watch for the sitesvc guard message; if it appears, sitesvc started first and + will restart cleanly. + +Expect a short window during the server restart where the API is unavailable. +Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID. + +## Risks + +| Risk | Mitigation | +|---|---| +| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable | +| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion | +| sitesvc deployed before server | Boot guard refuses to start | +| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff | +| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change | +| Frontend missed a renamed route | Full manual pass over every route in the UI before release | + +## Follow-on + +With `Instance` established, spec 1 (`licensing-core`) can define a license +payload that binds to `instance_id` without inventing a word the codebase does +not use. diff --git a/docs/superpowers/specs/2026-07-24-licensing-core-design.md b/docs/superpowers/specs/2026-07-24-licensing-core-design.md new file mode 100644 index 0000000..b8ec673 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-licensing-core-design.md @@ -0,0 +1,294 @@ +# Spec 1 — Licensing Core + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`) +Ships: independently. Adds a package and a CLI; changes no running behaviour. + +## Context + +Licenses are **offline-verified signed blobs**. A Vantage server checks a +signature and an expiry date and asks nobody's permission. That choice buys +self-hosted installs that work in air-gapped networks and a control plane with no +licensing availability dependency. + +It costs revocation. Once issued, a license is valid until it expires, whatever +Paddle later says. Every other decision in the programme follows from accepting +that: Self Hosted is annual-only so the unenforceable window is bounded, and +cancellation takes effect at term end rather than immediately (spec 5). + +This spec defines the payload, the signing and verification, and a CLI to issue +licenses by hand. It deliberately lands before the admin site so that specs 1+2 +together give working licensing with no new service to operate. + +## Goals + +1. One struct, in `shared`, read identically by the verifier and the issuer. +2. Verification that needs no network, no clock sync beyond a rough one, and no + configuration. +3. A hand-issuance path good enough to run production on until spec 3 lands. + +## Non-goals + +- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance + history. +- Deciding tier contents. Tiers are data; the values in this spec are the + initial seed, and spec 3's `plans` table becomes their home. +- Any phone-home, revocation list or online check. There is none, anywhere, by + design. + +## Design + +### Package + +`shared/license/`, inside the module created by spec 0a: + +``` +shared/license/ +├── license.go # License, Limits, feature constants +├── sign.go # Sign, build-tagged out of the server binary +├── verify.go # Verify, Parse +├── keys.go # trustedPublicKeys +└── license_test.go +``` + +Uses `github.com/hyperboloide/lk` (ed25519, base32 encoding). + +### Payload + +```go +package license + +type License struct { + ID string `json:"id"` // uuid, for support and audit + InstanceID string `json:"instance_id"` // the instance this license is bound to + AccountID string `json:"account_id"` // admin-side customer, informational + InstanceName string `json:"instance_name"` // display only + Tier string `json:"tier"` // "free" | "professional" | "self_hosted" + Deployment string `json:"deployment"` // "cloud" | "self_hosted" + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Limits Limits `json:"limits"` + Features []string `json:"features"` +} + +type Limits struct { + MaxServers int `json:"max_servers"` // -1 means unlimited + MaxSecretGroups int `json:"max_secret_groups"` + MaxChannels int `json:"max_channels"` +} + +const ( + FeatureConsole = "console" // browser SSH/RDP/VNC + FeatureOIDC = "oidc" // per-instance single sign-on +) + +const ( + TierFree = "free" + TierProfessional = "professional" + TierSelfHosted = "self_hosted" + + DeploymentCloud = "cloud" + DeploymentSelfHosted = "self_hosted" +) +``` + +`InstanceID` is **always populated**. There is no unbound license: the +self-hosted purchase flow (spec 4) links the instance UUID before the license is +issued, so binding happens at signing time. This removes the claim endpoint, the +best-effort phone-home and the multi-claim reconciliation that an unbound design +would have needed. + +**The server never branches on `Tier`.** It reads `Limits` and `Features` only. +`Tier` exists for display, support and analytics. Adding a tier, or changing what +a tier includes, must never require a server release. + +### Tier seed values + +Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into +each license at issue, so changing the table never rewrites an issued license — +the same principle as `workflow_runs.steps_snapshot`. + +| | Free | Professional | Self Hosted | +|---|---|---|---| +| `deployment` | `cloud` | `cloud` | `self_hosted` | +| `max_servers` | 3 | -1 | -1 | +| `max_secret_groups` | 1 | -1 | -1 | +| `max_channels` | 1 | -1 | -1 | +| `console` | no | yes | yes | +| `oidc` | no | yes | yes | +| billing term | monthly, £0 | monthly or annual | **annual only** | + +Free is cloud-only. A self-hosted install can never hold a valid Free license +because Free is only ever signed with `deployment: "cloud"`, and verification +rejects a deployment mismatch. There is no server-side flag to edit. + +### Signing + +```go +//go:build !noSign + +func Sign(l License, privateKeyHex string) (string, error) +``` + +Marshals to canonical JSON, signs with lk, returns the base32 blob. + +`Sign` is excluded from the server binary with a build tag. The server has no +reason to hold signing code and there is no reason to ship it into a customer's +data centre. + +The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the +CLI now, the admin backend from spec 3. It is never in the repo, never in an +image, never in the control plane's environment. + +### Verification + +```go +type VerifyOpts struct { + InstanceID string // required: the verifier's own instance + Deployment string // required: "cloud" or "self_hosted" + Now time.Time // injectable for tests +} + +type Result struct { + License License + State State // Valid, Expired, Invalid + Reason string +} + +const ( + StateValid State = "valid" + StateExpired State = "expired" + StateInvalid State = "invalid" +) + +func Verify(blob string, opts VerifyOpts) Result +``` + +Checks, in order, stopping at the first failure: + +1. Blob decodes and the signature verifies against one of `trustedPublicKeys`. + Failure → `Invalid`, reason `bad_signature`. +2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason + `deployment_mismatch`. This is the check that makes Free cloud-only. +3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason + `instance_mismatch`. +4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`. +5. Otherwise `Valid`. + +**`Expired` and `Invalid` are distinct states and the caller treats them +differently in messaging** (spec 2), even though both degrade the instance the +same way. A customer whose card failed and a customer who pasted the wrong blob +need different words. + +`Parse(blob) (License, error)` verifies the signature only, ignoring binding and +expiry. Used by the admin site to display a license and by support to inspect a +blob a customer has emailed in. Never used for enforcement. + +Clock skew: no tolerance is applied. Terms are a month or a year; a server whose +clock is wrong by enough to matter has bigger problems, and a tolerance window is +a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future, +which is the signal that a clock is badly off. + +### Key management + +```go +// trustedPublicKeys is ordered. Index 0 is the current signing key. +// To rotate: prepend the new key, ship a server release, then reissue. +// Remove a retired key only after every license signed with it has expired. +var trustedPublicKeys = []string{ + "", +} +``` + +A slice from day one even though it holds one entry, because retrofitting a +single-key verifier into a multi-key one during an incident is not a thing to +plan for. + +Public keys are compiled in. They are not configurable, because a configurable +trust root is a licensing bypass: a self-hosted operator could point it at a +keypair they generated. + +Key generation is a documented one-off: + +``` +go run ./shared/license/cmd/lkgen keypair +``` + +prints a private key hex for the vault and a public key hex to paste into +`keys.go`. The private key is stored in a password manager and in the admin +service's environment. **If it is lost, no new licenses can be issued for any +existing customer without a server release.** Back it up in two places. + +### CLI issuer + +`shared/license/cmd/lkctl`, built only for internal use: + +``` +lkctl keypair +lkctl issue --instance-id= --instance-name="Acme" \ + --tier=professional --deployment=cloud \ + --term=1y [--account-id=] [--out=acme.lic] +lkctl inspect +``` + +`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table +compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an +explicit `--expires=RFC3339`. + +This is the production issuance path until spec 3 ships. It is kept afterwards +for support and disaster recovery — if the admin service is down and a customer's +license expires, a blob can still be cut by hand. + +Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its +`licenses` table from `inspect` output when it takes over. + +## Testing + +`shared/license` is pure and needs no database, so this suite is fast and +thorough. Written test-first. + +1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload. +2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`. +3. Tampering with intent: re-sign a payload with a *different* keypair → + `Invalid`. This is the test that proves an attacker cannot mint licenses. +4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the + future → `Valid`. +5. Deployment mismatch: a Free (`cloud`) license verified with + `Deployment: "self_hosted"` → `Invalid`, `deployment_mismatch`. +6. Instance mismatch: correct signature, different `InstanceID` → `Invalid`, + `instance_mismatch`. +7. Check order: a blob that is both expired *and* instance-mismatched reports + `instance_mismatch`, not `Expired`. Order is part of the contract because the + reason drives the message. +8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed + with a key not in the slice does not. +9. `Parse` returns the payload for an expired and for a mismatched license, and + errors for a bad signature. +10. Unicode and long instance names survive the round trip. +11. Golden blob: a fixture blob checked into the repo, signed with a **test-only** + keypair, must keep verifying. This catches an accidental change to the + canonical JSON encoding, which would silently invalidate every issued + license in the field. + +Test 11 matters more than it looks. The encoding is part of the wire format. + +## Verification before merge + +1. `go test ./shared/license/...` passes, including the golden fixture. +2. `lkctl keypair` → `lkctl issue` → `lkctl inspect` round trips at the command + line. +3. `go build -tags noSign ./server/...` succeeds and + `go tool nm` on the resulting binary shows no `license.Sign` symbol. +4. The production keypair is generated, the private half stored in two places, + and the public half committed in `keys.go`. + +## Risks + +| Risk | Mitigation | +|---|---| +| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist | +| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire | +| Canonical encoding changes | Golden fixture test | +| Signing code shipped to customers | Build tag plus a symbol check in verification | +| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only | diff --git a/docs/superpowers/specs/2026-07-24-paddle-billing-design.md b/docs/superpowers/specs/2026-07-24-paddle-billing-design.md new file mode 100644 index 0000000..dcd3570 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-paddle-billing-design.md @@ -0,0 +1,271 @@ +# Spec 5 — Paddle Billing + +Date: 2026-07-24 +Status: Design approved, not implemented +Depends on: spec 3 (`admin-backend`) +Ships: after spec 3. Can be developed in parallel with spec 4. + +## Context + +Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the +customer billing portal. This spec connects Paddle's subscription lifecycle to +the licence issuance functions spec 3 defines, and moves cloud signup off +sitesvc. + +The central constraint, restated because every table below follows from it: +**licences are offline-verified, so nothing Paddle says can revoke one early.** +Cancellation takes effect when the licence expires. Self Hosted is annual-only to +bound that window; the alternative — a customer holding a valid key for eleven +months after cancelling a monthly plan — is not acceptable. + +## Goals + +1. A catalog in Paddle sandbox, promotable to production by configuration alone. +2. Webhooks that issue and renew licences reliably, including under retries and + out-of-order delivery. +3. Cloud signup owned by one service instead of two. + +## Non-goals + +- Building any part of billing Paddle already provides. +- Usage-based or metered pricing. Tiers are flat. +- Proration logic. Paddle handles money; we react to the resulting subscription + state. + +## Design + +### Catalog + +Three products, created in **sandbox** first. Production is a configuration +change: the same `plans` rows carry different `paddle_product_id` and +`paddle_price_ids`, selected by `PADDLE_ENV`. + +| Product | Prices | Notes | +|---|---|---| +| Vantage Free | monthly, £0 | Yes, a real £0 subscription. It gives every account a Paddle customer, a lifecycle, and an upgrade path with no special-case code. | +| Vantage Professional | monthly, annual | Cloud | +| Vantage Self Hosted | **annual only** | No monthly price exists, so the offline-revocation window is at most a year | + +**No price ID is ever hard-coded.** They live in `plans.paddle_price_ids` and are +edited through the staff UI. A price change in Paddle is a data edit, not a +deploy. + +`custom_data` on every checkout carries `{ account_id, instance_id, tier }`. This +is what lets a webhook route without a lookup table, and it is why the +self-hosted flow creates the instance record *before* checkout completes. + +### Checkout + +Paddle Checkout, overlay mode, in the admin site. + +**Cloud upgrade** — instance exists, `instance_id` in `custom_data`, existing +Paddle customer reused. + +**Self-hosted purchase** — the instance does not exist yet. Order: + +``` +Customer creates an admin-site account (verified email) +Account row created, then an admin_instances row with status awaiting_link + and a generated placeholder instance record +Checkout opened with account_id and that instance row's id in custom_data +subscription.created fires → subscription recorded, status awaiting_link, + NO licence issued +Customer pastes their install's UUID → instance_id set, status active + → licence issued and delivered +``` + +The instance row exists before payment so the webhook has something to attach to. +The licence is not issued until the UUID is known, because a licence with no +instance to bind to cannot be signed — spec 1 has no unbound licence. + +A customer who pays and never links has a subscription and no licence. Spec 4's +staff dashboard flags `awaiting_link` older than 48 hours, and a reminder email +goes out at 24 hours and 72 hours. This is the most likely place for a paying +customer to get stuck, so it gets active chasing rather than a support queue. + +### Webhooks + +`POST /api/paddle/webhook`, signature-verified with `PADDLE_WEBHOOK_SECRET`. +An unsigned or badly signed request is rejected `401` and logged — never +processed. + +**Idempotency is mandatory.** Paddle retries. Every event ID is recorded in +`paddle_events` with a unique index before processing; a duplicate returns `200` +without acting. `200` on duplicates matters — returning an error would make +Paddle retry a message we have already handled, forever. + +| Event | Action | +|---|---| +| `subscription.created` | Record the subscription. Cloud: issue and inject. Self-hosted: leave `awaiting_link`, issue nothing. | +| `subscription.updated` | Tier or term changed: issue a replacement licence at the new tier, supersede the old. Cloud injects; self-hosted emails a new blob and flags the site. Reflects Paddle's resulting state; no proration maths here. | +| `subscription.canceled` | Mark `cancelled`. **No licence action.** The current licence runs to expiry, then the instance degrades per spec 2. | +| `subscription.past_due` | Mark `past_due`, notify the customer, flag for staff. Licence untouched. Dunning is Paddle's job; ours is not to punish a retryable card failure. | +| `transaction.completed` where the transaction is a subscription renewal | Issue the next term's licence, supersede, inject or email. Reset `RelinkCount`. | +| `transaction.payment_failed` | Record for staff visibility. No licence action. | +| `customer.updated` | Sync `billing_email` onto the account. | + +Out-of-order delivery is handled by making every handler a function of the +subscription's *current* state as reported in the event payload, rather than of +the transition. An `updated` arriving before its `created` creates the +subscription row and proceeds. + +Renewal licences are issued with a **3-day grace** past the period end (spec 3), +so a webhook delayed by hours never produces a gap in coverage. + +**Webhook failures must be visible.** Every failed handler writes to +`admin_audit` and appears on the staff dashboard. A licence that silently failed +to issue is a customer who paid and got nothing. + +### Cancellation, stated plainly + +When a customer cancels: + +- Paddle stops billing at period end. +- We issue no further licences. +- Their current licence keeps working until it expires — up to a month for + Professional monthly, up to a year for Self Hosted. +- On expiry the instance degrades per spec 2: monitors keep running, changes stop. + +This is documented in the terms and shown on the cancellation confirmation +screen, because a customer who cancels and sees their instance keep working +should understand why rather than assume the cancellation failed. + +### Signup migration off sitesvc + +Cloud signup currently lives in sitesvc: `site_pending_signups`, a verification +email, and provisioning on link click. It now needs to also create an Account, a +Paddle customer, a Free subscription and a licence. + +**Signup moves to the admin backend.** The form stays on the marketing site where +customers find it, but it posts to admin instead of sitesvc. sitesvc keeps the +contact form only. + +The reason is the one `CLAUDE.md` already names: provisioning logic duplicated +across services drifts. Spec 0a removed the second copy; adding signup to admin +while leaving it in sitesvc would create a third. + +New flow, preserving every property of the current one: + +``` +Marketing site form → POST /api/signup on admin + → pending record, password bcrypt cost 12, token 32 random bytes, + only the SHA-256 hash stored, 24h expiry, TTL index + → verification email +Link opened → FindOneAndDelete the pending record (atomic, before provisioning) + → shared.CreateInstance + shared.CreateUser in the control plane + → Account created + → Paddle customer created, Free subscription created + → Free licence issued and injected + → redirect to APP_LOGIN_URL with {slug} filled in +``` + +Properties that must survive, verified by test: + +- Nothing written to `instances` or `users` until the link is opened. +- `FindOneAndDelete` before provisioning, so a double-clicked link cannot create + two instances. +- Instance rollback if the owner insert fails, refusing to delete an instance + that has users. +- Re-submitting for the same address replaces the pending record. +- Rate limited to 3 signups per IP per hour, plus the honeypot field. + +Two failure modes are new, because provisioning now spans two systems: + +- **Paddle customer creation fails** — the instance and user are already created. + Complete the signup, record the account with an empty `PaddleCustomerID`, issue + the Free licence anyway, and flag for staff. A new customer must never be + blocked from signing in by a billing-system hiccup. +- **Licence issuance fails** — the instance exists with no licence and is + read-only. Flagged for staff, and the 15-minute reconciliation job (spec 3) + retries. The customer can log in and sees the licence banner. + +Both resolve toward "the customer gets in", because a signup that half-fails +silently is worse than either outcome. + +sitesvc changes: signup, verify, `site_pending_signups` and the provisioning +calls are deleted. `SITE_API_URL` gains a sibling for the admin endpoint, or the +marketing site posts signup to `ADMIN_API_URL` directly — the latter, so the two +form targets are explicit rather than implied. + +### Configuration + +| Variable | Required | Notes | +|---|---|---| +| `PADDLE_ENV` | yes | `sandbox` or `production`; selects which price IDs the plans table serves | +| `PADDLE_API_KEY` | yes | server-side API | +| `PADDLE_CLIENT_TOKEN` | yes | browser checkout; baked into the admin site build | +| `PADDLE_WEBHOOK_SECRET` | yes | signature verification. Boot fails without it — an unverified webhook endpoint is an endpoint anyone can issue licences through | +| `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template | + +### Cutover + +Signup migration is the only user-visible switch: + +1. Deploy admin with signup enabled; sitesvc still serving its own. +2. Point the marketing site's form at admin. Deploy. +3. Let sitesvc's outstanding pending signups expire naturally — 24 hours — while + its verify endpoint stays live. **Do not delete the collection until it is + empty**, or someone's verification link breaks. +4. Deploy sitesvc with signup removed. + +## Testing + +**Webhooks:** + +1. Each event type produces its documented action against a mock Paddle payload. +2. Replaying an event ID is a no-op returning `200`. +3. A bad signature is rejected `401` and processes nothing. +4. `subscription.updated` before `subscription.created` creates the subscription + and applies the update. +5. `subscription.canceled` issues nothing and leaves the current licence intact. +6. `past_due` leaves the licence intact and flags the account. +7. Renewal issues the next term, supersedes, resets `RelinkCount`, and the new + `ExpiresAt` is period end plus 3 days. +8. A handler failure writes to `admin_audit` and surfaces on the dashboard. + +**Checkout:** + +9. `custom_data` round-trips account, instance and tier through to the webhook. +10. Self-hosted checkout leaves the instance `awaiting_link` with no licence. +11. Linking after checkout issues the licence. + +**Signup:** + +12. Nothing is written to `instances` or `users` before the link is opened. +13. A double-clicked verification link creates exactly one instance. +14. Owner-insert failure rolls the instance back; rollback refuses an instance + with users. +15. Re-submitting replaces the pending record and invalidates the earlier link. +16. Rate limit and honeypot both reject. +17. Paddle customer creation failure still completes signup and issues the Free + licence. +18. Licence issuance failure still lets the user log in, showing the banner. +19. Expired pending records are dropped by the TTL index. + +## Verification before merge + +1. Full suite green. +2. Against Paddle **sandbox**, end to end for each tier: checkout with a test + card, confirm the licence is issued, confirm the instance reports `valid`. +3. Trigger a sandbox renewal and confirm the next term's licence arrives and is + injected. +4. Cancel in sandbox and confirm the licence keeps working to expiry, then the + instance degrades correctly — monitors still running. +5. Replay every webhook from Paddle's dashboard and confirm no duplicate licences + are created. +6. Full signup end to end through admin, then confirm the new user can log into + their control-plane instance and sees a valid Free licence. +7. Confirm sitesvc's pending-signup collection is empty before its signup code is + removed. + +## Risks + +| Risk | Mitigation | +|---|---| +| Duplicate licences from webhook retries | Unique index on event ID, checked before processing | +| Webhook missed entirely | 15-minute reconciliation job (spec 3) compares subscription state against issued licences | +| Cancellation not enforceable until expiry | Accepted, bounded by term; Self Hosted annual-only; stated in terms and on the cancellation screen | +| Signup cutover breaks in-flight verification links | Staged cutover; sitesvc's verify stays live until its collection is empty | +| Sandbox price IDs reaching production | `PADDLE_ENV` selects them from the plans table; environment badge in the admin site | +| Webhook endpoint unauthenticated | Signature verification mandatory; boot fails without the secret | +| Customer pays and never links | Reminder emails at 24h and 72h, staff dashboard alert at 48h | diff --git a/docs/superpowers/specs/2026-07-24-shared-module-design.md b/docs/superpowers/specs/2026-07-24-shared-module-design.md new file mode 100644 index 0000000..66cc283 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-shared-module-design.md @@ -0,0 +1,286 @@ +# Spec 0a — Shared Module Extraction + +Date: 2026-07-24 +Status: Design approved, not implemented +Ships: independently. No dependency on any other licensing spec. + +## Context + +Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no +root `go.mod` and no `go.work`. + +`sitesvc` writes into the same MongoDB collections the control plane reads, but +cannot import the control plane, so it carries hand-copied duplicates: + +- `sitesvc/internal/models/models.go` — `Org` and `User` mirrored field for field +- `sitesvc/internal/provision/provision.go` — `Slugify`, `ReservedSlugs`, + `MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules + +Both files carry comments saying they must be changed in lockstep with the +control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the +match. **The duplication has already drifted.** The control plane's `CreateOrg` +resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop, +while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)` +helper. They currently agree by luck, not by construction. + +The licensing programme adds a fourth service (`admin`) that writes the license +blob onto the same tenant document. Adding a third copy of these rules is not +acceptable. This spec removes the duplication before any licensing code is +written. + +This spec is a **pure refactor**. No database document changes. No behaviour +changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in +spec 0b, deliberately kept separate so that a failed deploy has one suspect +rather than two. + +## Goals + +1. One authoritative definition of every document shape written by more than one + service. +2. One authoritative definition of provisioning rules (slug, bcrypt cost, + creation, rollback). +3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not + on the control plane. The original design intent survives; only the copying + dies. +4. The agent is untouched. + +## Non-goals + +- Renaming anything. That is spec 0b. +- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`, + `server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`, + `audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control + plane touches them, and hoisting them would make `shared` a dumping ground. +- Merging the repo into a single module. + +## Design + +### Module layout + +``` +vantage/ +├── go.work # NEW: server, sitesvc, shared (NOT agent) +├── shared/ # NEW module: github.com/mrhid6/vantage/shared +│ ├── go.mod +│ ├── models/ +│ │ ├── org.go # Org +│ │ ├── user.go # User, RoleOwner/RoleAdmin/RoleMember, ValidRole +│ │ └── settings.go # Settings, AlertSettings, EmailSettings, SecretsSettings +│ ├── provision/ +│ │ ├── slug.go # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits +│ │ ├── org.go # CreateOrg +│ │ ├── user.go # CreateUser, BcryptCost +│ │ └── rollback.go # RollbackOrg +│ └── indexes/ +│ └── indexes.go # EnsureCoreIndexes +├── server/ # replace => ../shared +├── sitesvc/ # replace => ../shared +└── agent/ # untouched +``` + +`go.work`: + +``` +go 1.26 + +use ( + ./shared + ./server + ./sitesvc +) +``` + +Each consumer's `go.mod` also carries an explicit replace: + +``` +require github.com/mrhid6/vantage/shared v0.0.0 +replace github.com/mrhid6/vantage/shared => ../shared +``` + +Both are needed. `go.work` makes editors, `go test ./...` and local tooling work +across modules. The `replace` directives make Docker builds work whether or not +`go.work` is present, and stop `go build` outside the workspace from silently +trying to resolve `shared` from the network. + +`shared` depends only on `go.mongodb.org/mongo-driver/v2`, +`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import +gin, redis, guac or anything else from the control plane's tree — that is what +keeps sitesvc small. + +### What moves + +**`shared/models`** — the three documents written by more than one service: + +| Type | From | Written by | +|---|---|---| +| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin | +| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc | +| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later | + +`Settings` moves now rather than later because spec 3's admin service reads it, +and moving it later would mean a second round of import churn across both +services. + +`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`, +and the control plane does not know the collection exists. + +**`shared/provision`** — the rules, promoted from private helpers to a real API: + +```go +const ( + MinSlugLength = 3 + MaxSlugLength = 40 + BcryptCost = 12 +) + +var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ } + +func Slugify(name string) string +func BaseSlug(name string) (string, error) // validates length + reserved +func NextSlug(base string, attempt int) string + +// CreateOrg resolves a free slug and inserts. The caller supplies the +// collection handle so shared does not own a Mongo connection. +func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error) + +func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error) + +// RollbackOrg deletes an org only if it has no users. Refuses otherwise. +func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error +``` + +`shared.CreateOrg` becomes the single implementation. The control plane's +`services.CreateOrg` shrinks to a wrapper that calls it and then runs +`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know +about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not +seed, which is the behaviour it has today. + +Note on the slug loop: it is count-then-insert and therefore racy. It is safe +only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling +`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must +not lose that. Document the reliance in a comment at the loop. + +**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes +on `users.email` and `orgs.slug`. Both services call it at boot; creating an +existing index is a no-op. These indexes are a security property, not an +optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so +duplicates would break the OIDC cross-org guard), so the shared version is +**fatal on failure** for both callers. + +Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`, +`EnsureWorkflowIndexes`) stay in the server and keep their current +fatal/warn behaviour. + +### What is deleted + +- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only +- `sitesvc/internal/provision/` — deleted entirely +- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into + `shared/provision`; pending-signup storage stays + +### Docker and CI + +Both Go Dockerfiles currently build with the module directory as context: + +```dockerfile +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build ... ./cmd +``` + +A `replace => ../shared` cannot resolve from that context. Build contexts move +to the repo root: + +```dockerfile +WORKDIR /src +COPY shared/go.mod shared/go.sum ./shared/ +COPY server/go.mod server/go.sum ./server/ +RUN cd server && go mod download +COPY shared/ ./shared/ +COPY server/ ./server/ +ARG VERSION=dev +RUN cd server && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd +``` + +The two-stage copy keeps the dependency-download layer cached, which is the +reason the current Dockerfiles are written the way they are. + +`.gitea/workflows/server-deploy.yml` must set `context: .` and +`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The +`web` and `site` image builds are unaffected. + +`agent-release.yml` is untouched. The agent is not in the workspace, has no +`replace`, and cross-compiles exactly as it does today. + +### Error handling + +No new error paths. `shared/provision` returns the same error strings the two +callers produce today so that API responses do not change. The one place to be +careful is wording: sitesvc says "organisation" and the control plane says +"organization". `shared` standardises on **"organisation"**; the control plane's +two error strings change spelling. This is user-visible in API error text and is +called out here so it is a decision rather than an accident. + +## Testing + +**No automated tests.** Decision taken 2026-07-24: the repo has no Go test suite +and one is not being started here. Verification is by compiler, `grep`, and +running both services end to end. + +That places the whole weight on three manual checks, which the implementation +plan makes mandatory steps rather than suggestions: + +1. **bson tag diff** — `diff` the `bson:"…"` tags of each moved struct against + the originals. A changed tag orphans production data silently, and this is + the only thing that catches it. +2. **Slug behaviour walkthrough** — a throwaway `main` printing `Slugify`, + `BaseSlug` and `NextSlug` output for a fixed input table, compared against + expected output recorded in the plan. +3. **End-to-end agreement** — sign up through sitesvc against a scratch + database, open the verification link, then log into the control plane with + those credentials. This is the check that proves the two services still agree + about the documents they share. If it passes, the refactor worked. + +Plus `grep` assertions that exactly one definition of `Slugify` and +`ReservedSlugs` survives repo-wide, and that no struct under `sitesvc/` carries +a `bson:"org_id"` tag. + +## Verification before merge + +Evidence required, not assertions: + +1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`. +2. `go vet ./...` clean in all three. +3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .` + both succeed from the repo root. +4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and + `shared` imports — no local struct redefinitions. +5. End-to-end against a scratch database: sitesvc signup form → verification link + → org and owner created → that owner logs into the control plane + successfully. This is the test that proves the two services still agree. +6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`. + +## Rollout + +Single release. `server` and `sitesvc` images must be deployed together — a skew +is harmless here (documents are unchanged) but there is no reason to split it. + +No database migration. No downtime. + +## Risks + +| Risk | Mitigation | +|---|---| +| Docker context change breaks CI | Verified locally by building both images from root before pushing | +| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move | +| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition | +| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings | + +## Follow-on + +Spec 0b (`instance-rename`) becomes a rename inside one module plus its +consumers, rather than a rename across three independent copies. That is the +whole reason this spec goes first. diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md new file mode 100644 index 0000000..a69e8ec --- /dev/null +++ b/docs/superpowers/specs/README.md @@ -0,0 +1,67 @@ +# Vantage Licensing Programme — Spec Index + +Seven specs, designed 2026-07-24. Build in this order. + +| # | Spec | Ships alone | Blocks | +|---|---|---|---| +| 0a | [shared-module](2026-07-24-shared-module-design.md) | yes | everything | +| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | yes | 1, 2, 3 | +| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | yes | 2, 3 | +| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | yes, with `lkctl`-issued licences | — | +| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | no | 4, 5 | +| 4 | [admin-site](2026-07-24-admin-site-design.md) | no | — | +| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | no | — | + +4 and 5 can run in parallel once 3 lands. + +## The shape + +``` +Account (admin only) + ├── Instance 1 cloud vantage.hostxtra.co.uk/ licence auto-injected + ├── Instance 2 cloud licence auto-injected + └── Instance 3 self-hosted customer's own deployment licence pasted by hand +``` + +The control plane knows only **Instance**. Accounts exist solely in the admin +service, because a self-hosted instance has no row in the cloud database at all. + +## Decisions that everything else follows from + +**Licences are offline-verified signed blobs.** ed25519 via +`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home +anywhere. This buys air-gapped self-hosting and means no Vantage instance ever +depends on the licensing service being up. It costs revocation: a licence is +valid until it expires whatever Paddle later says. Self Hosted is annual-only to +bound that window. + +**Every licence is bound to one instance UUID.** Self-hosted customers link their +UUID before the licence is signed, so there is no unbound licence and no claim +protocol. + +**Expiry degrades, it does not break.** Monitors keep executing, alerts keep +firing, agents keep their keys, in-flight workflow runs finish. Mutations stop. +Deletes and OS-update application stay open so a customer is never trapped +over-limit or unpatched. + +**Tiers are data, not code.** The server reads `Limits` and `Features` and never +branches on tier name. Tier contents live in the admin `plans` table and are +snapshotted into each issued licence, so editing a plan never rewrites history — +the same rule as `workflow_runs.steps_snapshot`. + +| | Free | Professional | Self Hosted | +|---|---|---|---| +| deployment | cloud only | cloud | self-hosted | +| max servers | 3 | unlimited | unlimited | +| max secret groups | 1 | unlimited | unlimited | +| max channels | 1 | unlimited | unlimited | +| console | no | yes | yes | +| OIDC | no | yes | yes | +| term | monthly, £0 | monthly or annual | annual only | + +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`. diff --git a/go.work b/go.work new file mode 100644 index 0000000..c525707 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.26.4 + +use ( + ./server + ./shared + ./sitesvc +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..298e44e --- /dev/null +++ b/go.work.sum @@ -0,0 +1,16 @@ +cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +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/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.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +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= diff --git a/server/Dockerfile b/server/Dockerfile index 89ad212..2cf4cfe 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,17 +1,22 @@ # Build stage +# +# Context is the repository root, not server/, because server depends on the +# shared module through a replace directive. FROM golang:1.26 AS builder -WORKDIR /app +WORKDIR /src -# Download dependencies first (layer cache) -COPY go.mod go.sum ./ -RUN go mod download +# Manifests first so the dependency layer caches independently of source edits. +COPY shared/go.mod shared/go.sum ./shared/ +COPY server/go.mod server/go.sum ./server/ +RUN cd server && go mod download -# Copy source and build -COPY . . +COPY shared/ ./shared/ +COPY server/ ./server/ ARG VERSION=dev -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd +RUN cd server && CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd # Runtime stage FROM scratch diff --git a/server/cmd/main.go b/server/cmd/main.go index a775cb4..12c8a22 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -19,9 +19,6 @@ func main() { mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017") dbName := getEnv("MONGO_DB", "vantage") - - - if os.Getenv("GRPC_HOST") == "" { log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)") } @@ -31,19 +28,13 @@ func main() { } log.Println("connected to MongoDB") - - - - - if err := services.EnsureAuthIndexes(); err != nil { - log.Fatalf("failed to ensure auth indexes: %v", err) - } + // Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id), + // so they must run before 0004 renames everything underneath them. if err := services.RunMigrations(); err != nil { log.Fatalf("migration failed: %v", err) } - - - + // 0002 must precede 0003: 0003 can create a "default" org, which pushes + // 0002 into its ambiguous multi-org branch. if err := services.MigrateSettingsOrg(); err != nil { log.Fatalf("settings org migration failed: %v", err) } @@ -51,13 +42,31 @@ func main() { log.Fatalf("missed org scope migration failed: %v", err) } + // 0004 renames orgs to instances. It must run BEFORE the index builders: + // EnsureAuthIndexes creates instances.slug, which would create an empty + // instances collection and make 0004 refuse to rename onto it. + migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute) + migErr := services.MigrateOrgToInstance(migCtx, db.Database) + migCancel() + if migErr != nil { + log.Fatalf("instance rename migration failed: %v", migErr) + } + + assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second) + assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database) + assertCancel() + if assertErr != nil { + log.Fatalf("scoped collection check failed: %v", assertErr) + } + + if err := services.EnsureAuthIndexes(); err != nil { + log.Fatalf("failed to ensure auth indexes: %v", err) + } + if err := services.EnsureSecretIndexes(); err != nil { log.Printf("warning: failed to ensure secret indexes: %v", err) } - - - if err := services.EnsureSettingsIndexes(); err != nil { log.Fatalf("failed to ensure settings indexes: %v", err) } @@ -66,14 +75,14 @@ func main() { log.Printf("warning: failed to ensure workflow indexes: %v", err) } - if orgIDs, err := services.ListOrgIDs(); err != nil { - log.Printf("warning: failed to list orgs for default step seeding: %v", err) + if instanceIDs, err := services.ListInstanceIDs(); err != nil { + log.Printf("warning: failed to list instances for default step seeding: %v", err) } else { - for _, orgID := range orgIDs { - if created, updated, err := services.SeedDefaultSteps(orgID); err != nil { - log.Printf("warning: failed to seed default steps for org %s: %v", orgID, err) + for _, instanceID := range instanceIDs { + if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil { + log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err) } else { - log.Printf("default steps seeded for org %s: %d created, %d updated", orgID, created, updated) + log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, created, updated) } } } @@ -86,7 +95,6 @@ func main() { } log.Println("connected to Redis") - go func() { ticker := time.NewTicker(2 * time.Minute) defer ticker.Stop() @@ -97,17 +105,14 @@ func main() { } }() - go func() { if err := grpcserver.StartGRPC(9090); err != nil { log.Fatalf("gRPC server error: %v", err) } }() - monitorsched.Start(context.Background()) - r := gin.New() r.Use(gin.Recovery()) r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}})) diff --git a/server/cmd/rename-rollback/main.go b/server/cmd/rename-rollback/main.go new file mode 100644 index 0000000..8ad1834 --- /dev/null +++ b/server/cmd/rename-rollback/main.go @@ -0,0 +1,87 @@ +// Command rename-rollback reverses migration 0004. +// +// Run it only as part of a decision to revert the release that introduced the +// instance rename. It renames instance_id back to org_id and restores the two +// collection names. Like the migration, it only renames — it deletes no +// documents. +// +// rename-rollback -uri mongodb://host:27017 -db vantage -confirm +package main + +import ( + "context" + "flag" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/services" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func main() { + uri := flag.String("uri", "mongodb://localhost:27017", "MongoDB URI") + dbName := flag.String("db", "vantage", "database name") + confirm := flag.Bool("confirm", false, "required; refuses to run without it") + flag.Parse() + + if !*confirm { + log.Fatal("refusing to run without -confirm") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + client, err := mongo.Connect(options.Client().ApplyURI(*uri)) + if err != nil { + log.Fatalf("connect: %v", err) + } + defer client.Disconnect(ctx) + + db := client.Database(*dbName) + + // Drop indexes keyed on instance_id first, for the same reason the forward + // migration drops the org_id ones: a unique index treats the missing field + // as null and rejects the second document the rename touches. + for _, c := range services.ScopedCollections { + if err := services.DropIndexesKeyedOn(ctx, db, c, "instance_id"); err != nil { + log.Fatalf("%v", err) + } + } + + for _, c := range services.ScopedCollections { + res, err := db.Collection(c).UpdateMany(ctx, + bson.M{"instance_id": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"instance_id": "org_id"}}, + ) + if err != nil { + log.Fatalf("rename instance_id in %s: %v", c, err) + } + if res.ModifiedCount > 0 { + log.Printf("%s: reverted %d document(s)", c, res.ModifiedCount) + } + } + + for _, r := range []struct{ from, to string }{ + {"instances", "orgs"}, + {"instance_oidc", "org_oidc"}, + } { + cmd := bson.D{ + {Key: "renameCollection", Value: *dbName + "." + r.from}, + {Key: "to", Value: *dbName + "." + r.to}, + } + if err := client.Database("admin").RunCommand(ctx, cmd).Err(); err != nil { + log.Printf("rename %s to %s: %v (continuing)", r.from, r.to, err) + continue + } + log.Printf("renamed collection %s to %s", r.from, r.to) + } + + // Remove the marker so a redeployed new binary re-runs the migration. + if _, err := db.Collection("migrations").DeleteOne(ctx, bson.M{"_id": "0004_org_to_instance"}); err != nil { + log.Printf("clear migration marker: %v", err) + } + + log.Println("rollback complete") +} diff --git a/server/go.mod b/server/go.mod index 757e4b2..46b15bc 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,6 +1,6 @@ module github.com/mrhid6/vantage/server -go 1.26 +go 1.26.4 require ( github.com/coreos/go-oidc/v3 v3.18.0 @@ -8,7 +8,8 @@ require ( github.com/google/uuid v1.6.0 github.com/redis/go-redis/v9 v9.20.1 github.com/wwt/guac v1.3.2 - go.mongodb.org/mongo-driver/v2 v2.2.2 + go.mongodb.org/mongo-driver/v2 v2.8.0 + golang.org/x/crypto v0.54.0 golang.org/x/oauth2 v0.36.0 google.golang.org/grpc v1.64.0 ) @@ -26,32 +27,33 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang/snappy v1.0.0 // indirect github.com/gorilla/websocket v1.4.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.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/mrhid6/vantage/shared v0.0.0 github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/sirupsen/logrus v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // 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.8.0 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text 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/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/mrhid6/vantage/shared => ../shared diff --git a/server/go.sum b/server/go.sum index 54c1a12..474a8d4 100644 --- a/server/go.sum +++ b/server/go.sum @@ -35,8 +35,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -47,8 +45,8 @@ github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvK github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= -github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -94,8 +92,8 @@ github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4= github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0= 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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +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= @@ -103,8 +101,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS 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.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c= -go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= +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.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -112,20 +110,20 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -133,16 +131,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc 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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +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= diff --git a/server/internal/api/channels.go b/server/internal/api/channels.go index 790edc5..6b0114c 100644 --- a/server/internal/api/channels.go +++ b/server/internal/api/channels.go @@ -19,7 +19,7 @@ func registerChannelRoutes(g *gin.RouterGroup) { } func listChannels(c *gin.Context) { - channels, err := services.ListChannels(auth.OrgID(c)) + channels, err := services.ListChannels(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -37,7 +37,7 @@ func createChannel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) return } - created, err := services.CreateChannel(auth.OrgID(c), &ch) + created, err := services.CreateChannel(auth.InstanceID(c), &ch) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -73,7 +73,7 @@ func updateChannel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - if err := services.UpdateChannel(auth.OrgID(c), c.Param("id"), upd); err != nil { + if err := services.UpdateChannel(auth.InstanceID(c), c.Param("id"), upd); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -81,7 +81,7 @@ func updateChannel(c *gin.Context) { } func deleteChannel(c *gin.Context) { - if err := services.DeleteChannel(auth.OrgID(c), c.Param("id")); err != nil { + if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -89,7 +89,7 @@ func deleteChannel(c *gin.Context) { } func testChannel(c *gin.Context) { - if err := services.TestChannel(auth.OrgID(c), c.Param("id")); err != nil { + if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } diff --git a/server/internal/api/console.go b/server/internal/api/console.go index 735d1e8..3aedbb3 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -13,9 +13,6 @@ import ( "github.com/wwt/guac" ) - - - func consoleConnect(c *gin.Context) { var body struct { ServerID string `json:"server_id" binding:"required"` @@ -30,13 +27,13 @@ func consoleConnect(c *gin.Context) { return } - srv, err := services.GetServer(auth.OrgID(c), body.ServerID) + srv, err := services.GetServer(auth.InstanceID(c), body.ServerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - sess, err := services.CreateConsoleSession(auth.OrgID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP()) + sess, err := services.CreateConsoleSession(auth.InstanceID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -48,20 +45,20 @@ func consoleConnect(c *gin.Context) { } if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") { - if err := services.StashConsoleRDPCreds(auth.OrgID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil { + if err := services.StashConsoleRDPCreds(auth.InstanceID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } if body.Protocol == "ssh" { - if err := services.SetConsoleSSHUser(auth.OrgID(c), sess.SessionID, body.SSHUsername); err != nil { + if err := services.SetConsoleSSHUser(auth.InstanceID(c), sess.SessionID, body.SSHUsername); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } - services.LogEvent(auth.OrgID(c), "console.opened", actorFromCtx(c), srv.ServerID, "", + services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "", "console session opened ("+body.Protocol+")") c.JSON(http.StatusOK, gin.H{ @@ -71,8 +68,6 @@ func consoleConnect(c *gin.Context) { }) } - - func queryIntDefault(r *http.Request, key string, def int) int { v, err := strconv.Atoi(r.URL.Query().Get(key)) if err != nil || v <= 0 { @@ -81,7 +76,6 @@ func queryIntDefault(r *http.Request, key string, def int) int { return v } - func consoleTunnel(c *gin.Context) { token := c.Query("token") sessionID, err := services.VerifySessionToken(token) @@ -89,36 +83,32 @@ func consoleTunnel(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - orgID := auth.OrgID(c) - sess, err := services.GetConsoleSession(orgID, sessionID) + instanceID := auth.InstanceID(c) + sess, err := services.GetConsoleSession(instanceID, sessionID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return } - - if actor := actorFromCtx(c); actor != sess.User { c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"}) return } - - if err := services.ConsumeSessionToken(orgID, sessionID); err != nil { + if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"}) return } - srv, err := services.GetServer(auth.OrgID(c), sess.ServerID) + srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - var privKey, passphrase string if sess.Protocol == "ssh" && sess.KeyID != "" { - privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID) + privKey, err = services.GetPrivateKey(auth.InstanceID(c), sess.KeyID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"}) return @@ -128,7 +118,7 @@ func consoleTunnel(c *gin.Context) { var rdpUser, rdpPass string if sess.Protocol == "rdp" || sess.Protocol == "vnc" { - rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(orgID, sessionID) + rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(instanceID, sessionID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"}) return @@ -145,7 +135,6 @@ func consoleTunnel(c *gin.Context) { guacdAddr = "guacd:4822" } - connect := func(r *http.Request) (guac.Tunnel, error) { config := guac.NewGuacamoleConfiguration() config.Protocol = gp.Protocol @@ -173,7 +162,7 @@ func consoleTunnel(c *gin.Context) { wsServer := guac.NewWebsocketServer(connect) wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) { - _ = services.EndConsoleSession(orgID, sessionID) + _ = services.EndConsoleSession(instanceID, sessionID) } wsServer.ServeHTTP(c.Writer, c.Request) } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 80b9e61..9157947 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -27,7 +27,6 @@ func RegisterRoutes(r *gin.Engine) { r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup) - r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus) r.POST("/auth/bootstrap", auth.HandleBootstrap) r.POST("/auth/login", auth.HandleLocalLogin) @@ -36,7 +35,6 @@ func RegisterRoutes(r *gin.Engine) { r.GET("/auth/oidc/start", auth.HandleOIDCStart) r.GET("/auth/oidc/callback", auth.HandleOIDCCallback) - apiGroup := r.Group("/api") apiGroup.Use(auth.Middleware()) { @@ -85,21 +83,21 @@ func RegisterRoutes(r *gin.Engine) { registerMonitorRoutes(apiGroup) registerChannelRoutes(apiGroup) - org := apiGroup.Group("/org") - org.Use(auth.RequireRole("owner", "admin")) + instance := apiGroup.Group("/instance") + instance.Use(auth.RequireRole("owner", "admin")) { - org.GET("/users", listOrgUsers) - org.POST("/users", createOrgUser) - org.PUT("/users/:id/role", updateOrgUserRole) - org.DELETE("/users/:id", deleteOrgUser) - org.GET("/oidc", getOrgOIDC) - org.PUT("/oidc", putOrgOIDC) + instance.GET("/users", listInstanceUsers) + instance.POST("/users", createInstanceUser) + instance.PUT("/users/:id/role", updateInstanceUserRole) + instance.DELETE("/users/:id", deleteInstanceUser) + instance.GET("/oidc", getInstanceOIDC) + instance.PUT("/oidc", putInstanceOIDC) } } } func listServers(c *gin.Context) { - servers, err := services.ListServers(auth.OrgID(c)) + servers, err := services.ListServers(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -108,7 +106,7 @@ func listServers(c *gin.Context) { } func createServer(c *gin.Context) { - s, token, err := services.CreateServer(auth.OrgID(c)) + s, token, err := services.CreateServer(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -121,12 +119,12 @@ func createServer(c *gin.Context) { } func newServer(c *gin.Context) { - s, token, err := services.CreateServer(auth.OrgID(c)) + s, token, err := services.CreateServer(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued") + services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued") giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { @@ -155,15 +153,14 @@ func newServer(c *gin.Context) { func getServer(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(auth.OrgID(c), id) + s, err := services.GetServer(auth.InstanceID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id) + assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id) - type serverResponse struct { *models.Server Keys interface{} `json:"keys"` @@ -176,8 +173,8 @@ func getServer(c *gin.Context) { func deleteServer(c *gin.Context) { id := c.Param("id") - s, _ := services.GetServer(auth.OrgID(c), id) - if err := services.DeleteServer(auth.OrgID(c), id); err != nil { + s, _ := services.GetServer(auth.InstanceID(c), id) + if err := services.DeleteServer(auth.InstanceID(c), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -185,7 +182,7 @@ func deleteServer(c *gin.Context) { if s != nil { hostname = s.Hostname } - services.LogEvent(auth.OrgID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname)) + services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -204,7 +201,7 @@ func generateKey(c *gin.Context) { body.Label = "generated" } - s, err := services.GetServer(auth.OrgID(c), id) + s, err := services.GetServer(auth.InstanceID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -222,7 +219,7 @@ func generateKey(c *gin.Context) { return } - services.LogEvent(auth.OrgID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType)) + services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType)) c.JSON(http.StatusAccepted, gin.H{ "message": "key generation command sent to agent", "command_id": cmdID, @@ -231,7 +228,7 @@ func generateKey(c *gin.Context) { } func listKeys(c *gin.Context) { - keys, err := services.ListKeys(auth.OrgID(c)) + keys, err := services.ListKeys(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -251,18 +248,18 @@ func createKey(c *gin.Context) { return } - key, err := services.CreateKey(auth.OrgID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase) + key, err := services.CreateKey(auth.InstanceID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label)) + services.LogEvent(auth.InstanceID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label)) c.JSON(http.StatusCreated, key) } func getPrivateKey(c *gin.Context) { id := c.Param("id") - plaintext, err := services.GetPrivateKey(auth.OrgID(c), id) + plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -272,13 +269,13 @@ func getPrivateKey(c *gin.Context) { func getKey(c *gin.Context) { id := c.Param("id") - key, err := services.GetKey(auth.OrgID(c), id) + key, err := services.GetKey(auth.InstanceID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) return } - assignments, _ := services.GetAssignmentsWithServers(auth.OrgID(c), id) + assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id) type keyResponse struct { *models.Key @@ -292,8 +289,8 @@ func getKey(c *gin.Context) { func deleteKey(c *gin.Context) { id := c.Param("id") - k, _ := services.GetKey(auth.OrgID(c), id) - if err := services.DeleteKey(auth.OrgID(c), id); err != nil { + k, _ := services.GetKey(auth.InstanceID(c), id) + if err := services.DeleteKey(auth.InstanceID(c), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -301,7 +298,7 @@ func deleteKey(c *gin.Context) { if k != nil { label = k.Label } - services.LogEvent(auth.OrgID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label)) + services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -315,12 +312,12 @@ func assignKey(c *gin.Context) { return } - a, err := services.AssignKey(auth.OrgID(c), keyID, body.ServerID) + a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID)) + services.LogEvent(auth.InstanceID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID)) c.JSON(http.StatusCreated, a) } @@ -328,11 +325,11 @@ func revokeAssignment(c *gin.Context) { keyID := c.Param("id") serverID := c.Param("serverId") - if err := services.RevokeAssignment(auth.OrgID(c), keyID, serverID); err != nil { + if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID)) + services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID)) c.JSON(http.StatusOK, gin.H{"revoked": true}) } @@ -347,7 +344,7 @@ func getLatestAgentVersion(c *gin.Context) { func updateAgent(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(auth.OrgID(c), id) + s, err := services.GetServer(auth.InstanceID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -358,7 +355,7 @@ func updateAgent(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version)) + services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version)) c.JSON(http.StatusAccepted, gin.H{ "message": "update command sent to agent", "version": version, @@ -367,7 +364,7 @@ func updateAgent(c *gin.Context) { func applyUpdates(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(auth.OrgID(c), id) + s, err := services.GetServer(auth.InstanceID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -377,7 +374,7 @@ func applyUpdates(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname)) + services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname)) c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"}) } @@ -444,7 +441,7 @@ func listAuditEvents(c *gin.Context) { limit = n } } - events, err := services.ListAuditEvents(auth.OrgID(c), limit) + events, err := services.ListAuditEvents(auth.InstanceID(c), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -453,7 +450,7 @@ func listAuditEvents(c *gin.Context) { } func getSettings(c *gin.Context) { - s, err := services.GetSettings(auth.OrgID(c)) + s, err := services.GetSettings(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -471,11 +468,11 @@ func saveSettings(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.SaveSettings(auth.OrgID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil { + if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated") + services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated") c.JSON(http.StatusOK, gin.H{"saved": true}) } diff --git a/server/internal/api/install_ps1.go b/server/internal/api/install_ps1.go index 81a1fab..2a97652 100644 --- a/server/internal/api/install_ps1.go +++ b/server/internal/api/install_ps1.go @@ -16,7 +16,7 @@ func handleInstallScriptWindows(c *gin.Context) { if giteaHost == "" { giteaHost = "gitea.example.com" } - + grpcHost := os.Getenv("GRPC_HOST") script := fmt.Sprintf( @@ -50,9 +50,6 @@ func handleInstallScriptWindows(c *gin.Context) { c.String(http.StatusOK, script) } - - - func handleUpdateScriptWindows(c *gin.Context) { giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index 0a63c6d..78010e5 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -22,7 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) { } func listMonitors(c *gin.Context) { - monitors, err := services.ListMonitors(auth.OrgID(c)) + monitors, err := services.ListMonitors(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -40,7 +40,7 @@ func createMonitor(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) return } - created, err := services.CreateMonitor(auth.OrgID(c), &m) + created, err := services.CreateMonitor(auth.InstanceID(c), &m) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -49,7 +49,7 @@ func createMonitor(c *gin.Context) { } func getMonitor(c *gin.Context) { - m, err := services.GetMonitor(auth.OrgID(c), c.Param("id")) + m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -105,7 +105,7 @@ func updateMonitor(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - if err := services.UpdateMonitor(auth.OrgID(c), c.Param("id"), upd); err != nil { + if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -113,7 +113,7 @@ func updateMonitor(c *gin.Context) { } func deleteMonitor(c *gin.Context) { - if err := services.DeleteMonitor(auth.OrgID(c), c.Param("id")); err != nil { + if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -121,7 +121,7 @@ func deleteMonitor(c *gin.Context) { } func getMonitorIncidents(c *gin.Context) { - m, err := services.GetMonitor(auth.OrgID(c), c.Param("id")) + m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -130,7 +130,7 @@ func getMonitorIncidents(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"}) return } - incidents, err := services.ListIncidents(auth.OrgID(c), c.Param("id"), 50) + incidents, err := services.ListIncidents(auth.InstanceID(c), c.Param("id"), 50) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -139,7 +139,7 @@ func getMonitorIncidents(c *gin.Context) { } func getMonitorUptime(c *gin.Context) { - m, err := services.GetMonitor(auth.OrgID(c), c.Param("id")) + m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -149,7 +149,7 @@ func getMonitorUptime(c *gin.Context) { return } since := time.Now().Add(-30 * 24 * time.Hour) - rollups, err := services.UptimeRollups(auth.OrgID(c), c.Param("id"), since) + rollups, err := services.UptimeRollups(auth.InstanceID(c), c.Param("id"), since) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/server/internal/api/org.go b/server/internal/api/org.go index e0d66e3..4eb9643 100644 --- a/server/internal/api/org.go +++ b/server/internal/api/org.go @@ -10,8 +10,8 @@ import ( "github.com/mrhid6/vantage/server/internal/services" ) -func listOrgUsers(c *gin.Context) { - users, err := services.ListUsers(auth.OrgID(c)) +func listInstanceUsers(c *gin.Context) { + users, err := services.ListUsers(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -19,14 +19,11 @@ func listOrgUsers(c *gin.Context) { c.JSON(http.StatusOK, users) } - - - func actorMayGrantOwner(c *gin.Context) bool { return auth.Role(c) == models.RoleOwner } -func createOrgUser(c *gin.Context) { +func createInstanceUser(c *gin.Context) { var body struct { Email string `json:"email"` Password string `json:"password"` @@ -47,7 +44,7 @@ func createOrgUser(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can create another owner"}) return } - u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role, "local") + u, err := services.CreateUser(auth.InstanceID(c), body.Email, body.Password, body.Role, "local") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -55,7 +52,7 @@ func createOrgUser(c *gin.Context) { c.JSON(http.StatusCreated, u) } -func updateOrgUserRole(c *gin.Context) { +func updateInstanceUserRole(c *gin.Context) { var body struct { Role string `json:"role"` } @@ -68,12 +65,12 @@ func updateOrgUserRole(c *gin.Context) { return } - orgID, targetID := auth.OrgID(c), c.Param("id") + instanceID, targetID := auth.InstanceID(c), c.Param("id") if targetID == auth.UserID(c) { c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"}) return } - target, err := services.GetUserInOrg(orgID, targetID) + target, err := services.GetUserInInstance(instanceID, targetID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return @@ -83,20 +80,20 @@ func updateOrgUserRole(c *gin.Context) { return } - if err := services.UpdateUserRole(orgID, targetID, body.Role); err != nil { + if err := services.UpdateUserRole(instanceID, targetID, body.Role); err != nil { c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } -func deleteOrgUser(c *gin.Context) { - orgID, targetID := auth.OrgID(c), c.Param("id") +func deleteInstanceUser(c *gin.Context) { + instanceID, targetID := auth.InstanceID(c), c.Param("id") if targetID == auth.UserID(c) { c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"}) return } - target, err := services.GetUserInOrg(orgID, targetID) + target, err := services.GetUserInInstance(instanceID, targetID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return @@ -106,7 +103,7 @@ func deleteOrgUser(c *gin.Context) { return } - if err := services.DeleteUser(orgID, targetID); err != nil { + if err := services.DeleteUser(instanceID, targetID); err != nil { c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()}) return } @@ -120,16 +117,15 @@ func orgUserErrStatus(err error) int { return http.StatusInternalServerError } -func getOrgOIDC(c *gin.Context) { - cfg, err := services.GetOrgOIDC(auth.OrgID(c)) +func getInstanceOIDC(c *gin.Context) { + cfg, err := services.GetInstanceOIDC(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false}) return } - - + c.JSON(http.StatusOK, gin.H{ - "org_id": cfg.OrgID, + "instance_id": cfg.InstanceID, "issuer": cfg.Issuer, "client_id": cfg.ClientID, "enabled": cfg.Enabled, @@ -138,7 +134,7 @@ func getOrgOIDC(c *gin.Context) { }) } -func putOrgOIDC(c *gin.Context) { +func putInstanceOIDC(c *gin.Context) { var body struct { Issuer string `json:"issuer"` ClientID string `json:"client_id"` @@ -149,11 +145,11 @@ func putOrgOIDC(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.SaveOrgOIDC(auth.OrgID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil { + if err := services.SaveOrgOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - auth.EvictOIDCProvider(auth.OrgID(c)) + auth.EvictOIDCProvider(auth.InstanceID(c)) c.JSON(http.StatusOK, gin.H{"saved": true}) } diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index a79378a..e65dc69 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -11,22 +11,13 @@ import ( "github.com/mrhid6/vantage/server/internal/services" ) - - var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) func validName(s string) bool { return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s) } - -const ctxSecretsOrgKey = "km_secrets_org" - - - - - - +const ctxSecretsInstanceKey = "km_secrets_instance" func secretsReadAuth() gin.HandlerFunc { return func(c *gin.Context) { @@ -36,29 +27,26 @@ func secretsReadAuth() gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) return } - orgID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):]) + instanceID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):]) if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - c.Set(ctxSecretsOrgKey, orgID) + c.Set(ctxSecretsInstanceKey, instanceID) c.Next() } } - - - func esoGetGroup(c *gin.Context) { group := c.Param("group") - - orgID := c.GetString(ctxSecretsOrgKey) - if orgID == "" { - + + instanceID := c.GetString(ctxSecretsInstanceKey) + if instanceID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - values, err := services.GetSecretGroupDecrypted(orgID, group) + values, err := services.GetSecretGroupDecrypted(instanceID, group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"}) return @@ -71,7 +59,7 @@ func esoGetGroup(c *gin.Context) { } func listSecretGroups(c *gin.Context) { - groups, err := services.ListSecretGroups(auth.OrgID(c)) + groups, err := services.ListSecretGroups(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -79,8 +67,6 @@ func listSecretGroups(c *gin.Context) { c.JSON(http.StatusOK, groups) } - - func createSecretGroup(c *gin.Context) { var body struct { Group string `json:"group" binding:"required"` @@ -104,17 +90,17 @@ func createSecretGroup(c *gin.Context) { return } } - if err := services.UpsertSecrets(auth.OrgID(c), body.Group, body.Values); err != nil { + if err := services.UpsertSecrets(auth.InstanceID(c), body.Group, body.Values); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", "))) + services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", "))) c.JSON(http.StatusCreated, gin.H{"group": body.Group}) } func getSecretGroup(c *gin.Context) { group := c.Param("group") - secrets, err := services.GetSecretGroup(auth.OrgID(c), group) + secrets, err := services.GetSecretGroup(auth.InstanceID(c), group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -126,7 +112,6 @@ func getSecretGroup(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets}) } - func putSecretGroup(c *gin.Context) { group := c.Param("group") if !validName(group) { @@ -148,11 +133,11 @@ func putSecretGroup(c *gin.Context) { return } } - if err := services.UpsertSecrets(auth.OrgID(c), group, values); err != nil { + if err := services.UpsertSecrets(auth.InstanceID(c), group, values); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", "))) + services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", "))) c.JSON(http.StatusOK, gin.H{"saved": true}) } @@ -165,42 +150,42 @@ func revealSecret(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - value, err := services.RevealSecret(auth.OrgID(c), group, body.Key) + value, err := services.RevealSecret(auth.InstanceID(c), group, body.Key) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key)) + services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key)) c.JSON(http.StatusOK, gin.H{"value": value}) } func deleteSecretKey(c *gin.Context) { group := c.Param("group") key := c.Param("key") - if err := services.DeleteSecret(auth.OrgID(c), group, key); err != nil { + if err := services.DeleteSecret(auth.InstanceID(c), group, key); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group)) + services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } func deleteSecretGroup(c *gin.Context) { group := c.Param("group") - if err := services.DeleteSecretGroup(auth.OrgID(c), group); err != nil { + if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group)) + services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } func rotateSecretsToken(c *gin.Context) { - token, err := services.RotateSecretsReadToken(auth.OrgID(c)) + token, err := services.RotateSecretsReadToken(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated") + services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated") c.JSON(http.StatusOK, gin.H{"token": token}) } diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 5f37a57..2040a5c 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -81,7 +81,7 @@ func streamServerRunLog(c *gin.Context) { sendNew := func() { f, err := os.Open(path) if err != nil { - return + return } defer f.Close() if _, err := f.Seek(offset, 0); err != nil { @@ -94,7 +94,7 @@ func streamServerRunLog(c *gin.Context) { break } offset += int64(n) - + for _, line := range splitSSE(buf[:n]) { _, _ = c.Writer.WriteString("data: " + line + "\n") } @@ -106,11 +106,11 @@ func streamServerRunLog(c *gin.Context) { ctx := c.Request.Context() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() - orgID := auth.OrgID(c) + instanceID := auth.InstanceID(c) for { sendNew() - if serverRunTerminal(orgID, runID, serverID) { - sendNew() + if serverRunTerminal(instanceID, runID, serverID) { + sendNew() _, _ = c.Writer.WriteString("event: done\ndata: end\n\n") flusher.Flush() return @@ -123,9 +123,8 @@ func streamServerRunLog(c *gin.Context) { } } - -func serverRunTerminal(orgID, runID, serverID string) bool { - r, err := services.GetRun(orgID, runID) +func serverRunTerminal(instanceID, runID, serverID string) bool { + r, err := services.GetRun(instanceID, runID) if err != nil { return true } @@ -141,15 +140,13 @@ func serverRunTerminal(orgID, runID, serverID string) bool { return true } - - func splitSSE(b []byte) []string { s := strings.ReplaceAll(string(b), "\r", "") return strings.Split(s, "\n") } func listSteps(c *gin.Context) { - steps, err := services.ListSteps(auth.OrgID(c)) + steps, err := services.ListSteps(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -158,7 +155,7 @@ func listSteps(c *gin.Context) { } func stepUsage(c *gin.Context) { - counts, err := services.StepUsageCounts(auth.OrgID(c)) + counts, err := services.StepUsageCounts(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -172,12 +169,12 @@ func createStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.CreateStep(auth.OrgID(c), s) + out, err := services.CreateStep(auth.InstanceID(c), s) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name)) + services.LogEvent(auth.InstanceID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name)) c.JSON(http.StatusCreated, out) } @@ -187,25 +184,25 @@ func updateStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.UpdateStep(auth.OrgID(c), c.Param("id"), s); err != nil { + if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated") + services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated") c.JSON(http.StatusOK, gin.H{"updated": true}) } func deleteStep(c *gin.Context) { - if err := services.DeleteStep(auth.OrgID(c), c.Param("id")); err != nil { + if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted") + services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted") c.JSON(http.StatusOK, gin.H{"deleted": true}) } func exportStep(c *gin.Context) { - b, err := services.ExportStep(auth.OrgID(c), c.Param("id")) + b, err := services.ExportStep(auth.InstanceID(c), c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -215,16 +212,16 @@ func exportStep(c *gin.Context) { } func seedDefaults(c *gin.Context) { - created, updated, err := services.SeedDefaultSteps(auth.OrgID(c)) + created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated)) + services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated)) c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated}) } -const maxStepBodyBytes = 1 << 20 +const maxStepBodyBytes = 1 << 20 func importStep(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes) @@ -233,12 +230,12 @@ func importStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.ImportStepToLibrary(auth.OrgID(c), body) + out, err := services.ImportStepToLibrary(auth.InstanceID(c), body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) + services.LogEvent(auth.InstanceID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) c.JSON(http.StatusCreated, out) } @@ -258,7 +255,7 @@ func parseStep(c *gin.Context) { } func listWorkflows(c *gin.Context) { - wfs, err := services.ListWorkflows(auth.OrgID(c)) + wfs, err := services.ListWorkflows(auth.InstanceID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -272,17 +269,17 @@ func createWorkflow(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.CreateWorkflow(auth.OrgID(c), w) + out, err := services.CreateWorkflow(auth.InstanceID(c), w) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name)) + services.LogEvent(auth.InstanceID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name)) c.JSON(http.StatusCreated, out) } func getWorkflow(c *gin.Context) { - w, err := services.GetWorkflow(auth.OrgID(c), c.Param("id")) + w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -296,12 +293,12 @@ func updateWorkflow(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.UpdateWorkflow(auth.OrgID(c), c.Param("id"), w); err != nil { + if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated") - updated, err := services.GetWorkflow(auth.OrgID(c), c.Param("id")) + services.LogEvent(auth.InstanceID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated") + updated, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -310,21 +307,21 @@ func updateWorkflow(c *gin.Context) { } func deleteWorkflow(c *gin.Context) { - if err := services.DeleteWorkflow(auth.OrgID(c), c.Param("id")); err != nil { + if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted") + services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted") c.JSON(http.StatusOK, gin.H{"deleted": true}) } func runWorkflow(c *gin.Context) { - runID, err := services.TriggerWorkflow(auth.OrgID(c), c.Param("id"), actorFromCtx(c)) + runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c)) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID)) + services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID)) c.JSON(http.StatusAccepted, gin.H{"run_id": runID}) } @@ -335,7 +332,7 @@ func listWorkflowRuns(c *gin.Context) { limit = n } } - runs, err := services.ListRuns(auth.OrgID(c), c.Param("id"), limit) + runs, err := services.ListRuns(auth.InstanceID(c), c.Param("id"), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -344,7 +341,7 @@ func listWorkflowRuns(c *gin.Context) { } func getRun(c *gin.Context) { - r, err := services.GetRun(auth.OrgID(c), c.Param("runId")) + r, err := services.GetRun(auth.InstanceID(c), c.Param("runId")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -353,10 +350,10 @@ func getRun(c *gin.Context) { } func cancelRun(c *gin.Context) { - if err := services.CancelRun(auth.OrgID(c), c.Param("runId")); err != nil { + if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent(auth.OrgID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled") + services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled") c.JSON(http.StatusOK, gin.H{"cancelled": true}) } diff --git a/server/internal/auth/orghost.go b/server/internal/auth/instancehost.go similarity index 90% rename from server/internal/auth/orghost.go rename to server/internal/auth/instancehost.go index 4281855..3853e19 100644 --- a/server/internal/auth/orghost.go +++ b/server/internal/auth/instancehost.go @@ -12,7 +12,7 @@ import ( ) type cachedOrg struct { - org *models.Org + org *models.Instance at time.Time } @@ -23,9 +23,6 @@ var ( const orgCacheTTL = 60 * time.Second - - - func appRootLabel() string { if v := os.Getenv("APP_ROOT_LABEL"); v != "" { return strings.ToLower(v) @@ -33,15 +30,13 @@ func appRootLabel() string { return "vantage" } - - func hostSlug(host string) string { host = strings.ToLower(host) if i := strings.IndexByte(host, ':'); i >= 0 { host = host[:i] } root := appRootLabel() - + parts := strings.Split(host, ".") if len(parts) < 3 { return "" @@ -55,7 +50,7 @@ func hostSlug(host string) string { return parts[0] } -func OrgFromHost(c *gin.Context) (*models.Org, bool) { +func InstanceFromHost(c *gin.Context) (*models.Instance, bool) { slug := hostSlug(c.Request.Host) if slug == "" { return nil, false @@ -67,10 +62,9 @@ func OrgFromHost(c *gin.Context) (*models.Org, bool) { } orgCacheMu.Unlock() - org, err := services.GetOrgBySlug(slug) + org, err := services.GetInstanceBySlug(slug) if err != nil || org == nil { - - + return nil, false } orgCacheMu.Lock() diff --git a/server/internal/auth/local.go b/server/internal/auth/local.go index 8b5df21..1069132 100644 --- a/server/internal/auth/local.go +++ b/server/internal/auth/local.go @@ -37,7 +37,7 @@ func HandleLocalLogin(c *gin.Context) { return } sessionID, err := SaveSession(c.Request.Context(), &Session{ - UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, + UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) @@ -50,13 +50,13 @@ func HandleLocalLogin(c *gin.Context) { func HandleBootstrapStatus(c *gin.Context) { var ( - n int64 - err error - orgName string + n int64 + err error + instName string ) - if org, ok := OrgFromHost(c); ok { - n, err = services.CountOrgUsers(org.OrgID) - orgName = org.Name + if inst, ok := InstanceFromHost(c); ok { + n, err = services.CountInstanceUsers(inst.InstanceID) + instName = inst.Name } else { n, err = services.CountUsers() } @@ -64,12 +64,9 @@ func HandleBootstrapStatus(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "org_name": orgName}) + c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "instance_name": instName}) } - - - func HandleBootstrap(c *gin.Context) { n, err := services.CountUsers() if err != nil { @@ -81,34 +78,34 @@ func HandleBootstrap(c *gin.Context) { return } var body struct { - OrgName string `json:"org_name"` - Email string `json:"email"` - Password string `json:"password"` + InstanceName string `json:"instance_name"` + Email string `json:"email"` + Password string `json:"password"` } - if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 { + if err := c.ShouldBindJSON(&body); err != nil || body.InstanceName == "" || body.Email == "" || len(body.Password) < 8 { c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"}) return } - orgCount, err := services.CountOrgs() + orgCount, err := services.CountInstances() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - var org *models.Org + var inst *models.Instance switch orgCount { case 0: - org, err = services.CreateOrg(body.OrgName) + inst, err = services.CreateInstance(body.InstanceName) case 1: - var existing *models.Org - existing, err = services.FirstOrg() + var existing *models.Instance + existing, err = services.FirstInstance() if err == nil { - org, err = services.AdoptOrg(existing.OrgID, body.OrgName) + inst, err = services.AdoptInstance(existing.InstanceID, body.InstanceName) } default: c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf( "cannot bootstrap: %d organizations already exist but no users do; "+ - "create the owner against the intended org rather than through setup, "+ + "create the owner against the intended inst rather than through setup, "+ "or remove the unintended orgs and retry", orgCount)}) return } @@ -116,20 +113,20 @@ func HandleBootstrap(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - u, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner", "local") + u, err := services.CreateUser(inst.InstanceID, body.Email, body.Password, "owner", "local") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } sessionID, err := SaveSession(c.Request.Context(), &Session{ - UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, + UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) return } SetSessionCookie(c, sessionID) - c.JSON(http.StatusCreated, gin.H{"org": org, "slug": org.Slug}) + c.JSON(http.StatusCreated, gin.H{"instance": inst, "slug": inst.Slug}) } func HandleMe(c *gin.Context) { @@ -143,14 +140,12 @@ func HandleMe(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) return } - - - - if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID { - c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"}) + + if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID { + c.JSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"}) return } - org, _ := services.GetOrg(sess.OrgID) - c.JSON(http.StatusOK, gin.H{"user": sess, "org": org}) + inst, _ := services.GetInstance(sess.InstanceID) + c.JSON(http.StatusOK, gin.H{"user": sess, "instance": inst}) } diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 1ea963b..ac2022d 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -14,9 +14,9 @@ func GetSessionFromContext(c *gin.Context) *Session { return sess } -func OrgID(c *gin.Context) string { +func InstanceID(c *gin.Context) string { if s := GetSessionFromContext(c); s != nil { - return s.OrgID + return s.InstanceID } return "" } @@ -62,15 +62,15 @@ func Middleware() gin.HandlerFunc { return } - if sess.OrgID == "" { + if sess.InstanceID == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"}) return } c.Set(ctxSessionKey, sess) - if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org host mismatch"}) + if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"}) return } diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index f4f8481..add8191 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -18,9 +18,9 @@ var ( provCache = map[string]*oidc.Provider{} ) -func EvictOIDCProvider(orgID string) { +func EvictOIDCProvider(instanceID string) { provMu.Lock() - delete(provCache, orgID) + delete(provCache, instanceID) provMu.Unlock() } @@ -32,17 +32,17 @@ func redirectURL(c *gin.Context) string { return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host) } -func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) { - cfg, err := services.GetOrgOIDC(orgID) +func providerForOrg(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) { + cfg, err := services.GetInstanceOIDC(instanceID) if err != nil || !cfg.Enabled { - return nil, nil, fmt.Errorf("org SSO not configured") + return nil, nil, fmt.Errorf("inst SSO not configured") } - secret, err := services.GetOrgOIDCSecret(orgID) + secret, err := services.GetInstanceOIDCSecret(instanceID) if err != nil { return nil, nil, err } provMu.Lock() - p := provCache[orgID] + p := provCache[instanceID] provMu.Unlock() if p == nil { p, err = oidc.NewProvider(ctx, cfg.Issuer) @@ -50,7 +50,7 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr return nil, nil, err } provMu.Lock() - provCache[orgID] = p + provCache[instanceID] = p provMu.Unlock() } return p, &oauth2.Config{ @@ -61,13 +61,13 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr } func HandleOIDCStart(c *gin.Context) { - org, ok := OrgFromHost(c) + inst, ok := InstanceFromHost(c) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"}) return } ctx := c.Request.Context() - _, oauthCfg, err := providerForOrg(ctx, c, org.OrgID) + _, oauthCfg, err := providerForOrg(ctx, c, inst.InstanceID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -77,7 +77,7 @@ func HandleOIDCStart(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"}) return } - if err := SaveStateOrg(ctx, state, org.OrgID); err != nil { + if err := SaveStateOrg(ctx, state, inst.InstanceID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"}) return } @@ -86,12 +86,12 @@ func HandleOIDCStart(c *gin.Context) { func HandleOIDCCallback(c *gin.Context) { ctx := c.Request.Context() - orgID, ok := ConsumeStateOrg(ctx, c.Query("state")) + instanceID, ok := ConsumeStateOrg(ctx, c.Query("state")) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) return } - provider, oauthCfg, err := providerForOrg(ctx, c, orgID) + provider, oauthCfg, err := providerForOrg(ctx, c, instanceID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -123,19 +123,19 @@ func HandleOIDCCallback(c *gin.Context) { email := strings.ToLower(claims.Email) u, err := services.GetUserByEmail(email) if err != nil { - - u, err = services.CreateUser(orgID, email, "", "member", "oidc") + + u, err = services.CreateUser(instanceID, email, "", "member", "oidc") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"}) return } - } else if u.OrgID != orgID { + } else if u.InstanceID != instanceID { c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"}) return } sessionID, err := SaveSession(ctx, &Session{ - UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name, + UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: claims.Name, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index b72a736..e0ecf0a 100644 --- a/server/internal/auth/session.go +++ b/server/internal/auth/session.go @@ -16,11 +16,11 @@ const sessionPrefix = "km:session:" const statePrefix = "km:state:" type Session struct { - UserID string `json:"user_id"` - OrgID string `json:"org_id"` - Role string `json:"role"` - Email string `json:"email"` - Name string `json:"name"` + UserID string `json:"user_id"` + InstanceID string `json:"instance_id"` + Role string `json:"role"` + Email string `json:"email"` + Name string `json:"name"` } var rdb *redis.Client @@ -71,14 +71,14 @@ func DeleteSession(ctx context.Context, id string) error { return rdb.Del(ctx, sessionPrefix+id).Err() } -func SaveStateOrg(ctx context.Context, state, orgID string) error { - return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err() +func SaveStateOrg(ctx context.Context, state, instanceID string) error { + return rdb.Set(ctx, statePrefix+state, instanceID, 10*time.Minute).Err() } func ConsumeStateOrg(ctx context.Context, state string) (string, bool) { - orgID, err := rdb.GetDel(ctx, statePrefix+state).Result() - if err != nil || orgID == "" { + instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result() + if err != nil || instanceID == "" { return "", false } - return orgID, true + return instanceID, true } diff --git a/server/internal/checker/checker.go b/server/internal/checker/checker.go index c2d8eba..475845d 100644 --- a/server/internal/checker/checker.go +++ b/server/internal/checker/checker.go @@ -12,7 +12,6 @@ import ( "time" ) - const ( TypeHTTP = "http" TypeTCP = "tcp" @@ -20,7 +19,6 @@ const ( TypeTLS = "tls" ) - type Spec struct { Type string URL string @@ -30,11 +28,10 @@ type Spec struct { ExpectedStatus int Keyword string TLSWarnDays int - Insecure bool + Insecure bool TimeoutSec int } - type Result struct { Up bool LatencyMs int @@ -50,7 +47,6 @@ func (s Spec) timeout() time.Duration { return time.Duration(t) * time.Second } - func Run(ctx context.Context, s Spec) Result { switch s.Type { case TypeHTTP: @@ -77,7 +73,7 @@ func runHTTP(ctx context.Context, s Spec) Result { } client := &http.Client{Timeout: s.timeout()} if s.Insecure { - client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} } start := time.Now() req, err := http.NewRequestWithContext(ctx, method, s.URL, nil) @@ -156,9 +152,6 @@ func runTLS(ctx context.Context, s Spec) Result { func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) } - - - func runICMP(ctx context.Context, s Spec) Result { dst, err := net.ResolveIPAddr("ip4", s.Host) if err != nil { @@ -188,18 +181,18 @@ func runICMP(ctx context.Context, s Spec) Result { if err != nil { return Result{LatencyMs: msSince(start), Message: "no reply"} } - + if n < 28 || peer.String() != dst.String() { continue } - if reply[20] == 0 { + if reply[20] == 0 { return Result{Up: true, LatencyMs: msSince(start)} } } } func icmpEcho(id, seq int) []byte { - + b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)} cs := icmpChecksum(b) b[2] = byte(cs >> 8) diff --git a/server/internal/grpc/codec.go b/server/internal/grpc/codec.go index 8113132..fea87ec 100644 --- a/server/internal/grpc/codec.go +++ b/server/internal/grpc/codec.go @@ -4,7 +4,6 @@ import ( "encoding/json" ) - type JSONCodec struct{} func (JSONCodec) Marshal(v interface{}) ([]byte, error) { @@ -16,5 +15,5 @@ func (JSONCodec) Unmarshal(data []byte, v interface{}) error { } func (JSONCodec) Name() string { - return "proto" + return "proto" } diff --git a/server/internal/grpc/pb/vantage.pb.go b/server/internal/grpc/pb/vantage.pb.go index 399cc52..24a0053 100644 --- a/server/internal/grpc/pb/vantage.pb.go +++ b/server/internal/grpc/pb/vantage.pb.go @@ -1,6 +1,3 @@ - - - package pb import ( @@ -11,8 +8,6 @@ import ( "google.golang.org/grpc/status" ) - - type RegisterRequest struct { ServerId string `json:"server_id"` PreRegToken string `json:"pre_reg_token"` @@ -47,8 +42,6 @@ type UploadKeyResponse struct { KeyId string `json:"key_id"` } - - type PackageUpdate struct { Name string `json:"name"` CurrentVersion string `json:"current_version,omitempty"` @@ -63,8 +56,6 @@ type ReportUpdatesRequest struct { type ReportUpdatesResponse struct{} - - type CPUReport struct { Model string `json:"model,omitempty"` Cores int `json:"cores,omitempty"` @@ -95,8 +86,6 @@ type InventoryReport struct { } type InventoryReportResponse struct{} - - type MonitorSpec struct { MonitorId string `json:"monitor_id"` Type string `json:"type"` @@ -119,11 +108,11 @@ type SyncMonitorsResponse struct { Monitors []MonitorSpec `json:"monitors,omitempty"` } type CheckResult struct { - MonitorId string `json:"monitor_id"` - Up bool `json:"up"` - LatencyMs int `json:"latency_ms"` - Message string `json:"message,omitempty"` - CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"` + MonitorId string `json:"monitor_id"` + Up bool `json:"up"` + LatencyMs int `json:"latency_ms"` + Message string `json:"message,omitempty"` + CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"` } type ReportChecksRequest struct { ServerId string `json:"server_id"` @@ -144,8 +133,6 @@ type ServerCommand struct { CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"` } - - type CleanupWorkspaceCmd struct { WorkspaceId string `json:"workspace_id"` } @@ -168,12 +155,12 @@ type GenerateKeyCmd struct { } type AgentMessage struct { - ServerId string `json:"server_id"` - AgentToken string `json:"agent_token"` - Ready *AgentReady `json:"ready,omitempty"` - Result *CommandResult `json:"result,omitempty"` - StepResult *StepResult `json:"step_result,omitempty"` - StepOutput *StepOutputChunk `json:"step_output,omitempty"` + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` + Ready *AgentReady `json:"ready,omitempty"` + Result *CommandResult `json:"result,omitempty"` + StepResult *StepResult `json:"step_result,omitempty"` + StepOutput *StepOutputChunk `json:"step_output,omitempty"` } type AgentReady struct{} @@ -189,8 +176,7 @@ type RunStepCmd struct { Script string `json:"script"` Env map[string]string `json:"env,omitempty"` TimeoutSeconds int `json:"timeout_seconds,omitempty"` - - + WorkspaceId string `json:"workspace_id,omitempty"` } @@ -209,8 +195,6 @@ type StepOutputChunk struct { Eof bool `json:"eof,omitempty"` } - - type Vantage_CommandStreamServer interface { Send(*ServerCommand) error Recv() (*AgentMessage, error) @@ -233,8 +217,6 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) { return m, nil } - - type Vantage_CommandStreamClient interface { Send(*AgentMessage) error Recv() (*ServerCommand, error) @@ -257,8 +239,6 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) { return m, nil } - - type VantageServer interface { Register(context.Context, *RegisterRequest) (*RegisterResponse, error) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) @@ -304,8 +284,6 @@ func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) err return status.Errorf(codes.Unimplemented, "method CommandStream not implemented") } - - type VantageClient interface { Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) @@ -389,8 +367,6 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO return &vantageCommandStreamClient{stream}, nil } - - func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) { s.RegisterService(&Vantage_ServiceDesc, srv) } diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 39972a3..75b1bbd 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -62,14 +62,12 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - - key, err := services.CreateKey(srv.OrgID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") + key, err := services.CreateKey(srv.InstanceID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") if err != nil { return nil, status.Errorf(codes.Internal, "failed to store key: %v", err) } - - if _, err := services.AssignKey(srv.OrgID, key.KeyID, srv.ServerID); err != nil { + if _, err := services.AssignKey(srv.InstanceID, key.KeyID, srv.ServerID); err != nil { log.Printf("failed to auto-assign generated key: %v", err) } @@ -112,7 +110,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe if err != nil { return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - monitors, err := services.ListMonitorsForRunner(srv.OrgID, srv.ServerID) + monitors, err := services.ListMonitorsForRunner(srv.InstanceID, srv.ServerID) if err != nil { return nil, status.Errorf(codes.Internal, "list monitors") } @@ -147,7 +145,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe t := time.Unix(r.CertExpiryUnix, 0) res.CertExpiry = &t } - if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil { + if err := services.IngestResult(srv.InstanceID, srv.ServerID, r.MonitorId, res); err != nil { log.Printf("ingest check %s: %v", r.MonitorId, err) } } @@ -155,7 +153,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe } func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error { - + msg, err := stream.Recv() if err != nil { return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err) @@ -176,8 +174,6 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err log.Printf("agent %s connected command stream", srv.ServerID) defer log.Printf("agent %s disconnected command stream", srv.ServerID) - - go func() { for { m, err := stream.Recv() @@ -224,15 +220,13 @@ func StartGRPC(port int) error { } s := grpc.NewServer( - - + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: 20 * time.Second, PermitWithoutStream: false, }), grpc.KeepaliveParams(keepalive.ServerParameters{ - - + Time: 45 * time.Second, Timeout: 10 * time.Second, }), diff --git a/server/internal/models/assignment.go b/server/internal/models/assignment.go index a0c3cd4..60e7614 100644 --- a/server/internal/models/assignment.go +++ b/server/internal/models/assignment.go @@ -8,7 +8,7 @@ import ( type Assignment struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` KeyID string `bson:"key_id" json:"key_id"` ServerID string `bson:"server_id" json:"server_id"` AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"` diff --git a/server/internal/models/audit.go b/server/internal/models/audit.go index 61c55fe..6c77456 100644 --- a/server/internal/models/audit.go +++ b/server/internal/models/audit.go @@ -7,12 +7,12 @@ import ( ) type AuditEvent struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"id"` - OrgID string `bson:"org_id" json:"org_id"` - EventType string `bson:"event_type" json:"event_type"` - Actor string `bson:"actor" json:"actor"` - ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"` - KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"` - Details string `bson:"details" json:"details"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + ID bson.ObjectID `bson:"_id,omitempty" json:"id"` + InstanceID string `bson:"instance_id" json:"instance_id"` + EventType string `bson:"event_type" json:"event_type"` + Actor string `bson:"actor" json:"actor"` + ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"` + KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"` + Details string `bson:"details" json:"details"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/server/internal/models/channel.go b/server/internal/models/channel.go index 839a633..10a93db 100644 --- a/server/internal/models/channel.go +++ b/server/internal/models/channel.go @@ -6,7 +6,6 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) - const ( ChannelWebhook = "webhook" ChannelSMTP = "smtp" @@ -15,16 +14,13 @@ const ( ChannelTelegram = "telegram" ) - - - type NotificationChannel struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` - ChannelID string `bson:"channel_id" json:"channel_id"` - Name string `bson:"name" json:"name"` - Type string `bson:"type" json:"type"` - Config map[string]string `bson:"config" json:"config"` - Enabled bool `bson:"enabled" json:"enabled"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + ChannelID string `bson:"channel_id" json:"channel_id"` + Name string `bson:"name" json:"name"` + Type string `bson:"type" json:"type"` + Config map[string]string `bson:"config" json:"config"` + Enabled bool `bson:"enabled" json:"enabled"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/server/internal/models/console_session.go b/server/internal/models/console_session.go index 3eda4ee..11d76b7 100644 --- a/server/internal/models/console_session.go +++ b/server/internal/models/console_session.go @@ -7,19 +7,17 @@ import ( ) type ConsoleSession struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` - SessionID string `bson:"session_id" json:"session_id"` - ServerID string `bson:"server_id" json:"server_id"` - Protocol string `bson:"protocol" json:"protocol"` - KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"` - User string `bson:"user" json:"user"` - StartedAt time.Time `bson:"started_at" json:"started_at"` - EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"` - ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"` + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + SessionID string `bson:"session_id" json:"session_id"` + ServerID string `bson:"server_id" json:"server_id"` + Protocol string `bson:"protocol" json:"protocol"` + KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"` + User string `bson:"user" json:"user"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"` + ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"` - - TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"` SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"` diff --git a/server/internal/models/instance.go b/server/internal/models/instance.go new file mode 100644 index 0000000..b473d7c --- /dev/null +++ b/server/internal/models/instance.go @@ -0,0 +1,7 @@ +package models + +import shared "github.com/mrhid6/vantage/shared/models" + +// Instance is defined in the shared module because sitesvc and the admin +// control plane write the same documents. +type Instance = shared.Instance diff --git a/server/internal/models/key.go b/server/internal/models/key.go index 86ad450..68faef6 100644 --- a/server/internal/models/key.go +++ b/server/internal/models/key.go @@ -8,12 +8,12 @@ import ( type Key struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` KeyID string `bson:"key_id" json:"key_id"` Label string `bson:"label" json:"label"` PublicKey string `bson:"public_key" json:"public_key"` Fingerprint string `bson:"fingerprint" json:"fingerprint"` - Source string `bson:"source" json:"source"` + Source string `bson:"source" json:"source"` GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"` PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"` HasPrivateKey bool `bson:"-" json:"has_private_key"` diff --git a/server/internal/models/monitor.go b/server/internal/models/monitor.go index 8f7860b..940bad2 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -6,7 +6,6 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) - const ( MonitorHTTP = "http" MonitorTCP = "tcp" @@ -14,15 +13,12 @@ const ( MonitorTLS = "tls" ) - const ( StatusUp = "up" StatusDown = "down" StatusPending = "pending" ) - - const RunnerServer = "server" type MonitorTarget struct { @@ -33,29 +29,29 @@ type MonitorTarget struct { ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"` Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"` TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"` - Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"` + Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"` } type MonitorState struct { - Status string `bson:"status" json:"status"` + Status string `bson:"status" json:"status"` LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"` LatencyMs int `bson:"latency_ms" json:"latency_ms"` Message string `bson:"message,omitempty" json:"message,omitempty"` CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"` - Fails int `bson:"fails" json:"fails"` + Fails int `bson:"fails" json:"fails"` LastNotifiedAt *time.Time `bson:"last_notified_at,omitempty" json:"last_notified_at,omitempty"` } type Monitor struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` Name string `bson:"name" json:"name"` - Type string `bson:"type" json:"type"` + Type string `bson:"type" json:"type"` Target MonitorTarget `bson:"target" json:"target"` IntervalSec int `bson:"interval_sec" json:"interval_sec"` - Runner string `bson:"runner" json:"runner"` - Retries int `bson:"retries" json:"retries"` + Runner string `bson:"runner" json:"runner"` + Retries int `bson:"retries" json:"retries"` Enabled bool `bson:"enabled" json:"enabled"` ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"` State MonitorState `bson:"state" json:"state"` @@ -63,7 +59,7 @@ type Monitor struct { } type Incident struct { - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` IncidentID string `bson:"incident_id" json:"incident_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` StartedAt time.Time `bson:"started_at" json:"started_at"` @@ -72,9 +68,9 @@ type Incident struct { } type Rollup struct { - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` MonitorID string `bson:"monitor_id" json:"monitor_id"` - PeriodStart time.Time `bson:"period_start" json:"period_start"` + PeriodStart time.Time `bson:"period_start" json:"period_start"` Checks int `bson:"checks" json:"checks"` UpCount int `bson:"up_count" json:"up_count"` SumLatency int64 `bson:"sum_latency" json:"sum_latency"` diff --git a/server/internal/models/org.go b/server/internal/models/org.go deleted file mode 100644 index 0a863a6..0000000 --- a/server/internal/models/org.go +++ /dev/null @@ -1,15 +0,0 @@ -package models - -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Org struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` - Name string `bson:"name" json:"name"` - Slug string `bson:"slug" json:"slug"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` -} diff --git a/server/internal/models/org_oidc.go b/server/internal/models/org_oidc.go index 2f1d095..b6bead8 100644 --- a/server/internal/models/org_oidc.go +++ b/server/internal/models/org_oidc.go @@ -8,7 +8,7 @@ import ( type OrgOIDC struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` Issuer string `bson:"issuer" json:"issuer"` ClientID string `bson:"client_id" json:"client_id"` ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"` diff --git a/server/internal/models/secret.go b/server/internal/models/secret.go index bec0903..b0a94b0 100644 --- a/server/internal/models/secret.go +++ b/server/internal/models/secret.go @@ -6,18 +6,15 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) - - type Secret struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` Group string `bson:"group" json:"group"` Key string `bson:"key" json:"key"` EncryptedValue string `bson:"encrypted_value" json:"-"` UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` } - type GroupSummary struct { Group string `json:"group"` KeyCount int `json:"key_count"` diff --git a/server/internal/models/server.go b/server/internal/models/server.go index fe685bb..4fc2feb 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -45,7 +45,7 @@ type Inventory struct { type Server struct { ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` ServerID string `bson:"server_id" json:"server_id"` Hostname string `bson:"hostname" json:"hostname"` IPAddress string `bson:"ip_address" json:"ip_address"` diff --git a/server/internal/models/settings.go b/server/internal/models/settings.go index 3e4b767..9a74317 100644 --- a/server/internal/models/settings.go +++ b/server/internal/models/settings.go @@ -1,42 +1,10 @@ package models -import ( - "time" +import shared "github.com/mrhid6/vantage/shared/models" - "go.mongodb.org/mongo-driver/v2/bson" +type ( + Settings = shared.Settings + AlertSettings = shared.AlertSettings + EmailSettings = shared.EmailSettings + SecretsSettings = shared.SecretsSettings ) - -type AlertSettings struct { - Enabled bool `bson:"enabled" json:"enabled"` - WebhookURL string `bson:"webhook_url" json:"webhook_url"` - OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"` -} - -type EmailSettings struct { - Enabled bool `bson:"enabled" json:"enabled"` - SMTPHost string `bson:"smtp_host" json:"smtp_host"` - SMTPPort int `bson:"smtp_port" json:"smtp_port"` - Username string `bson:"username" json:"username"` - Password string `bson:"password" json:"password"` - FromAddr string `bson:"from_addr" json:"from_addr"` - ToAddrs []string `bson:"to_addrs" json:"to_addrs"` - UseTLS bool `bson:"use_tls" json:"use_tls"` -} - - - -type SecretsSettings struct { - ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"` - ReadTokenSet bool `bson:"-" json:"read_token_set"` - RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"` -} - -type Settings struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - OrgID string `bson:"org_id" json:"org_id"` - Alerts AlertSettings `bson:"alerts" json:"alerts"` - Email EmailSettings `bson:"email" json:"email"` - Secrets SecretsSettings `bson:"secrets" json:"secrets"` - - WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` -} diff --git a/server/internal/models/user.go b/server/internal/models/user.go index 1a4707c..6c5a8e5 100644 --- a/server/internal/models/user.go +++ b/server/internal/models/user.go @@ -1,35 +1,13 @@ package models -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - +import shared "github.com/mrhid6/vantage/shared/models" +type User = shared.User const ( - RoleOwner = "owner" - RoleAdmin = "admin" - RoleMember = "member" + RoleOwner = shared.RoleOwner + RoleAdmin = shared.RoleAdmin + RoleMember = shared.RoleMember ) -func ValidRole(role string) bool { - switch role { - case RoleOwner, RoleAdmin, RoleMember: - return true - } - return false -} - -type User struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - UserID string `bson:"user_id" json:"user_id"` - OrgID string `bson:"org_id" json:"org_id"` - Email string `bson:"email" json:"email"` - PasswordHash string `bson:"password_hash,omitempty" json:"-"` - Role string `bson:"role" json:"role"` - AuthSource string `bson:"auth_source" json:"auth_source"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` -} +func ValidRole(role string) bool { return shared.ValidRole(role) } diff --git a/server/internal/models/workflow.go b/server/internal/models/workflow.go index 759588c..a7ad86b 100644 --- a/server/internal/models/workflow.go +++ b/server/internal/models/workflow.go @@ -14,16 +14,16 @@ type InputParam struct { type WorkflowStep struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` StepID string `bson:"step_id" json:"step_id"` Name string `bson:"name" json:"name"` Description string `bson:"description" json:"description"` - Interpreter string `bson:"interpreter" json:"interpreter"` + Interpreter string `bson:"interpreter" json:"interpreter"` Script string `bson:"script" json:"script"` DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"` DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"` SecretRefs []string `bson:"secret_refs" json:"secret_refs"` - Source string `bson:"source" json:"source"` + Source string `bson:"source" json:"source"` Slug string `bson:"slug,omitempty" json:"slug,omitempty"` CreatedAt time.Time `bson:"created_at" json:"created_at"` UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` @@ -33,7 +33,7 @@ type WorkflowStepRef struct { StepID string `bson:"step_id,omitempty" json:"step_id,omitempty"` Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"` Order int `bson:"order" json:"order"` - OnFailure string `bson:"on_failure" json:"on_failure"` + OnFailure string `bson:"on_failure" json:"on_failure"` MaxRetries int `bson:"max_retries" json:"max_retries"` Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"` Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"` @@ -46,7 +46,7 @@ type StepOverride struct { type Workflow struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` WorkflowID string `bson:"workflow_id" json:"workflow_id"` Name string `bson:"name" json:"name"` TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"` @@ -55,7 +55,6 @@ type Workflow struct { UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` } - type ResolvedStep struct { Order int `bson:"order" json:"order"` Name string `bson:"name" json:"name"` @@ -70,7 +69,7 @@ type ResolvedStep struct { type StepRun struct { Order int `bson:"order" json:"order"` Name string `bson:"name" json:"name"` - Status string `bson:"status" json:"status"` + Status string `bson:"status" json:"status"` Attempts int `bson:"attempts" json:"attempts"` ExitCode int `bson:"exit_code" json:"exit_code"` LogOffset int64 `bson:"log_offset" json:"log_offset"` @@ -82,7 +81,7 @@ type StepRun struct { type ServerRun struct { ServerID string `bson:"server_id" json:"server_id"` Hostname string `bson:"hostname" json:"hostname"` - Status string `bson:"status" json:"status"` + Status string `bson:"status" json:"status"` StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"` FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` RunEnv map[string]string `bson:"run_env" json:"run_env"` @@ -91,12 +90,12 @@ type ServerRun struct { type WorkflowRun struct { ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - OrgID string `bson:"org_id" json:"org_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` RunID string `bson:"run_id" json:"run_id"` WorkflowID string `bson:"workflow_id" json:"workflow_id"` Name string `bson:"name" json:"name"` Steps []ResolvedStep `bson:"steps_snapshot" json:"steps_snapshot"` - Status string `bson:"status" json:"status"` + Status string `bson:"status" json:"status"` TriggeredBy string `bson:"triggered_by" json:"triggered_by"` StartedAt time.Time `bson:"started_at" json:"started_at"` FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"` diff --git a/server/internal/monitorsched/scheduler.go b/server/internal/monitorsched/scheduler.go index 5b90837..1d7f11c 100644 --- a/server/internal/monitorsched/scheduler.go +++ b/server/internal/monitorsched/scheduler.go @@ -40,7 +40,7 @@ func loop(ctx context.Context) { mu.Lock() defer mu.Unlock() - + for id, r := range active { m, ok := want[id] if !ok || m.IntervalSec != r.intervalSec { @@ -48,7 +48,7 @@ func loop(ctx context.Context) { delete(active, id) } } - + for id, m := range want { if _, ok := active[id]; ok { continue @@ -86,7 +86,7 @@ func runMonitor(ctx context.Context, m models.Monitor) { } } - run() + run() t := time.NewTicker(interval) defer t.Stop() for { diff --git a/server/internal/notify/dispatch.go b/server/internal/notify/dispatch.go index 00e9545..b4d7fd1 100644 --- a/server/internal/notify/dispatch.go +++ b/server/internal/notify/dispatch.go @@ -1,6 +1,3 @@ - - - package notify import ( @@ -10,7 +7,6 @@ import ( "github.com/mrhid6/vantage/server/internal/models" ) - type Event struct { MonitorName string Type string @@ -20,7 +16,6 @@ type Event struct { Time time.Time } - func (e Event) title() string { verb := "recovered" if e.NewStatus == models.StatusDown { @@ -33,7 +28,6 @@ func (e Event) title() string { return s } - func Dispatch(ch models.NotificationChannel, ev Event) error { switch ch.Type { case models.ChannelWebhook: @@ -51,7 +45,6 @@ func Dispatch(ch models.NotificationChannel, ev Event) error { } } - func Test(ch models.NotificationChannel) error { return Dispatch(ch, Event{ MonitorName: "Test monitor", diff --git a/server/internal/notify/http.go b/server/internal/notify/http.go index 703223b..fa06b7c 100644 --- a/server/internal/notify/http.go +++ b/server/internal/notify/http.go @@ -28,7 +28,6 @@ func postJSON(target string, payload any) error { return nil } - func dispatchWebhook(ch models.NotificationChannel, ev Event) error { target := ch.Config["url"] if target == "" { diff --git a/server/internal/notify/smtp.go b/server/internal/notify/smtp.go index 3537716..dd4e058 100644 --- a/server/internal/notify/smtp.go +++ b/server/internal/notify/smtp.go @@ -13,13 +13,6 @@ import ( const smtpTimeout = 15 * time.Second - - - - - - - func dispatchSMTP(ch models.NotificationChannel, ev Event) error { host := ch.Config["host"] port := ch.Config["port"] @@ -36,7 +29,6 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error { } _ = conn.SetDeadline(time.Now().Add(smtpTimeout)) - if port == "465" { conn = tls.Client(conn, &tls.Config{ServerName: host}) } diff --git a/server/internal/notify/template.go b/server/internal/notify/template.go index 49031cf..756383a 100644 --- a/server/internal/notify/template.go +++ b/server/internal/notify/template.go @@ -10,7 +10,6 @@ import ( "github.com/mrhid6/vantage/server/internal/models" ) - const ( colBg = "#0f1117" colSurface = "#1a1d27" @@ -23,7 +22,6 @@ const ( colDanger = "#ef4444" ) - func statusColor(status string) string { switch status { case models.StatusUp: @@ -35,8 +33,6 @@ func statusColor(status string) string { } } - - func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) { var buf strings.Builder w := multipart.NewWriter(&buf) @@ -150,7 +146,6 @@ func htmlEmail(ev Event) string { ) } - func textEmail(ev Event) string { return strings.Join([]string{ ev.title(), diff --git a/server/internal/services/audit.go b/server/internal/services/audit.go index 82ac79b..9f0a9d9 100644 --- a/server/internal/services/audit.go +++ b/server/internal/services/audit.go @@ -11,25 +11,25 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func LogEvent(orgID, eventType, actor, serverID, keyID, details string) { +func LogEvent(instanceID, eventType, actor, serverID, keyID, details string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() event := models.AuditEvent{ - OrgID: orgID, - EventType: eventType, - Actor: actor, - ServerID: serverID, - KeyID: keyID, - Details: details, - CreatedAt: time.Now(), + InstanceID: instanceID, + EventType: eventType, + Actor: actor, + ServerID: serverID, + KeyID: keyID, + Details: details, + CreatedAt: time.Now(), } if _, err := db.Col("audit_logs").InsertOne(ctx, event); err != nil { log.Printf("audit log error: %v", err) } } -func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) { +func ListAuditEvents(instanceID string, limit int64) ([]models.AuditEvent, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -37,7 +37,7 @@ func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) { SetSort(bson.D{{Key: "created_at", Value: -1}}). SetLimit(limit) - cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"org_id": orgID}, opts) + cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"instance_id": instanceID}, opts) if err != nil { return nil, err } diff --git a/server/internal/services/channels.go b/server/internal/services/channels.go index 70d5efc..b79fd68 100644 --- a/server/internal/services/channels.go +++ b/server/internal/services/channels.go @@ -13,10 +13,10 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func ListChannels(orgID string) ([]models.NotificationChannel, error) { +func ListChannels(instanceID string) ([]models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1})) + cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1})) if err != nil { return nil, err } @@ -27,11 +27,11 @@ func ListChannels(orgID string) ([]models.NotificationChannel, error) { return out, nil } -func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) { +func GetChannel(instanceID, channelID string) (*models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() var ch models.NotificationChannel - err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}).Decode(&ch) + err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}).Decode(&ch) if errors.Is(err, mongo.ErrNoDocuments) { return nil, nil } @@ -41,14 +41,13 @@ func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) { return &ch, nil } - -func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChannel, error) { +func GetChannels(instanceID string, channelIDs []string) ([]models.NotificationChannel, error) { if len(channelIDs) == 0 { return nil, nil } ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID, "channel_id": bson.M{"$in": channelIDs}}) + cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID, "channel_id": bson.M{"$in": channelIDs}}) if err != nil { return nil, err } @@ -59,11 +58,9 @@ func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChanne return out, nil } - - -func validateChannelIDs(orgID string, channelIDs []string) error { +func validateChannelIDs(instanceID string, channelIDs []string) error { for _, id := range channelIDs { - ch, err := GetChannel(orgID, id) + ch, err := GetChannel(instanceID, id) if err != nil { return err } @@ -74,10 +71,10 @@ func validateChannelIDs(orgID string, channelIDs []string) error { return nil } -func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) { +func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() - ch.OrgID = orgID + ch.InstanceID = instanceID ch.ChannelID = uuid.NewString() ch.CreatedAt = time.Now() if ch.Config == nil { @@ -89,23 +86,22 @@ func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.Notifi return ch, nil } -func UpdateChannel(orgID, channelID string, upd bson.M) error { +func UpdateChannel(instanceID, channelID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}, bson.M{"$set": upd}) + _, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd}) return err } -func DeleteChannel(orgID, channelID string) error { +func DeleteChannel(instanceID, channelID string) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}) + _, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}) return err } - -func TestChannel(orgID, channelID string) error { - ch, err := GetChannel(orgID, channelID) +func TestChannel(instanceID, channelID string) error { + ch, err := GetChannel(instanceID, channelID) if err != nil { return err } diff --git a/server/internal/services/console.go b/server/internal/services/console.go index 6c6a0b3..4bbbb01 100644 --- a/server/internal/services/console.go +++ b/server/internal/services/console.go @@ -17,7 +17,7 @@ import ( ) func sessionHMACKey() ([]byte, error) { - + k, err := encryptionKey() if err != nil { return nil, err @@ -29,7 +29,6 @@ func sessionHMACKey() ([]byte, error) { func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } - func SignSessionToken(sessionID string, ttl time.Duration) (string, error) { key, err := sessionHMACKey() if err != nil { @@ -42,7 +41,6 @@ func SignSessionToken(sessionID string, ttl time.Duration) (string, error) { return payload + "." + b64(mac.Sum(nil)), nil } - func VerifySessionToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) != 3 { @@ -86,10 +84,6 @@ func portOr(v, def int) string { return strconv.Itoa(v) } - - - - func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) { host := srv.IPAddress switch protocol { @@ -129,19 +123,19 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra } } -func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) { +func CreateConsoleSession(instanceID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s := &models.ConsoleSession{ - OrgID: orgID, - SessionID: uuid.NewString(), - ServerID: serverID, - Protocol: protocol, - KeyID: keyID, - User: user, - ClientIP: clientIP, - StartedAt: time.Now(), + InstanceID: instanceID, + SessionID: uuid.NewString(), + ServerID: serverID, + Protocol: protocol, + KeyID: keyID, + User: user, + ClientIP: clientIP, + StartedAt: time.Now(), } if _, err := db.Col("console_sessions").InsertOne(ctx, s); err != nil { return nil, err @@ -149,19 +143,17 @@ func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP strin return s, nil } -func GetConsoleSession(orgID, sessionID string) (*models.ConsoleSession, error) { +func GetConsoleSession(instanceID, sessionID string) (*models.ConsoleSession, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var s models.ConsoleSession - if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "org_id": orgID}).Decode(&s); err != nil { + if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "instance_id": instanceID}).Decode(&s); err != nil { return nil, err } return &s, nil } - - -func StashConsoleRDPCreds(orgID, sessionID, username, password string) error { +func StashConsoleRDPCreds(instanceID, sessionID, username, password string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() u, err := encryptString(username) @@ -173,17 +165,14 @@ func StashConsoleRDPCreds(orgID, sessionID, username, password string) error { return err } _, err = db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "org_id": orgID}, + bson.M{"session_id": sessionID, "instance_id": instanceID}, bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}}, ) return err } - - - -func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, err error) { - s, err := GetConsoleSession(orgID, sessionID) +func ConsumeConsoleRDPCreds(instanceID, sessionID string) (username, password string, err error) { + s, err := GetConsoleSession(instanceID, sessionID) if err != nil { return "", "", err } @@ -203,31 +192,27 @@ func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _ = db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "org_id": orgID}, + bson.M{"session_id": sessionID, "instance_id": instanceID}, bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}}, ) return username, password, nil } - -func SetConsoleSSHUser(orgID, sessionID, username string) error { +func SetConsoleSSHUser(instanceID, sessionID, username string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "org_id": orgID}, + bson.M{"session_id": sessionID, "instance_id": instanceID}, bson.M{"$set": bson.M{"ssh_username": username}}) return err } - - - -func ConsumeSessionToken(orgID, sessionID string) error { +func ConsumeSessionToken(instanceID, sessionID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() res, err := db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "org_id": orgID, "token_consumed_at": nil}, + bson.M{"session_id": sessionID, "instance_id": instanceID, "token_consumed_at": nil}, bson.M{"$set": bson.M{"token_consumed_at": now}}, ) if err != nil { @@ -239,12 +224,12 @@ func ConsumeSessionToken(orgID, sessionID string) error { return nil } -func EndConsoleSession(orgID, sessionID string) error { +func EndConsoleSession(instanceID, sessionID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() _, err := db.Col("console_sessions").UpdateOne(ctx, - bson.M{"session_id": sessionID, "org_id": orgID, "ended_at": nil}, + bson.M{"session_id": sessionID, "instance_id": instanceID, "ended_at": nil}, bson.M{"$set": bson.M{"ended_at": now}}, ) return err diff --git a/server/internal/services/coreindexes.go b/server/internal/services/coreindexes.go new file mode 100644 index 0000000..c538d92 --- /dev/null +++ b/server/internal/services/coreindexes.go @@ -0,0 +1,41 @@ +package services + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/shared/indexes" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsureAuthIndexes declares the indexes tenant isolation depends on. +// +// It lives here rather than in migrate.go so that the org-to-instance rename +// could touch it without touching migrations 0001 to 0003, which deliberately +// still speak the pre-rename shape. +// +// It MUST run after MigrateOrgToInstance. Creating the instances.slug index +// first would create an empty instances collection, and migration 0004 refuses +// to rename orgs when instances already exists. +func EnsureAuthIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // users.email and instances.slug are declared in the shared module so the + // control plane and sitesvc cannot disagree about them. + if err := indexes.EnsureCoreIndexes(ctx, db.Database); err != nil { + return err + } + + // instance_oidc is control-plane only, so its index stays here. + if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + return nil +} diff --git a/server/internal/services/crypto.go b/server/internal/services/crypto.go index 12c25ef..64f82e1 100644 --- a/server/internal/services/crypto.go +++ b/server/internal/services/crypto.go @@ -22,8 +22,6 @@ func encryptionKey() ([]byte, error) { return key, nil } - - func encryptString(plaintext string) (string, error) { key, err := encryptionKey() if err != nil { @@ -45,7 +43,6 @@ func encryptString(plaintext string) (string, error) { return hex.EncodeToString(sealed), nil } - func decryptString(ciphertextHex string) (string, error) { key, err := encryptionKey() if err != nil { diff --git a/server/internal/services/defaults.go b/server/internal/services/defaults.go index 8ccb9c6..ab2aba1 100644 --- a/server/internal/services/defaults.go +++ b/server/internal/services/defaults.go @@ -8,11 +8,11 @@ import ( "github.com/google/uuid" "github.com/mrhid6/vantage/server/internal/db" "github.com/mrhid6/vantage/server/internal/models" + "github.com/mrhid6/vantage/shared/provision" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo/options" ) - func DefaultStepsDir() string { dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR") if dir == "" { @@ -22,9 +22,6 @@ func DefaultStepsDir() string { return dir } - - - func readDefaultStepFiles() ([]models.WorkflowStep, error) { matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json")) if err != nil { @@ -41,7 +38,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) { continue } s.Source = "default" - s.Slug = Slugify(s.Name) + s.Slug = provision.Slugify(s.Name) if s.Slug == "" { continue } @@ -50,9 +47,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) { return out, nil } - - -func SeedDefaultSteps(orgID string) (created, updated int, err error) { +func SeedDefaultSteps(instanceID string) (created, updated int, err error) { steps, err := readDefaultStepFiles() if err != nil { return 0, 0, err @@ -61,7 +56,7 @@ func SeedDefaultSteps(orgID string) (created, updated int, err error) { defer cancel() col := db.Col("workflow_steps") for _, s := range steps { - filter := bson.M{"org_id": orgID, "slug": s.Slug, "source": "default"} + filter := bson.M{"instance_id": instanceID, "slug": s.Slug, "source": "default"} set := bson.M{ "name": s.Name, "description": s.Description, @@ -75,11 +70,11 @@ func SeedDefaultSteps(orgID string) (created, updated int, err error) { res, uerr := col.UpdateOne(ctx, filter, bson.M{ "$set": set, "$setOnInsert": bson.M{ - "org_id": orgID, - "step_id": uuid.New().String(), - "slug": s.Slug, - "source": "default", - "created_at": time.Now(), + "instance_id": instanceID, + "step_id": uuid.New().String(), + "slug": s.Slug, + "source": "default", + "created_at": time.Now(), }, }, options.UpdateOne().SetUpsert(true)) if uerr != nil { diff --git a/server/internal/services/dispatch.go b/server/internal/services/dispatch.go index 43258e2..be666d1 100644 --- a/server/internal/services/dispatch.go +++ b/server/internal/services/dispatch.go @@ -17,13 +17,10 @@ type commandDispatcher struct { channels map[string]chan *pb.ServerCommand } - - var Dispatcher = &commandDispatcher{ channels: make(map[string]chan *pb.ServerCommand), } - func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand { ch := make(chan *pb.ServerCommand, 16) d.mu.Lock() @@ -32,14 +29,12 @@ func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand { return ch } - func (d *commandDispatcher) Disconnect(serverID string) { d.mu.Lock() delete(d.channels, serverID) d.mu.Unlock() } - func (d *commandDispatcher) IsConnected(serverID string) bool { d.mu.RLock() _, ok := d.channels[serverID] @@ -62,15 +57,10 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err } } - - func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error { return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd}) } - - - func DispatchCleanupWorkspace(serverID, workspaceID string) { if !Dispatcher.IsConnected(serverID) { return @@ -81,7 +71,6 @@ func DispatchCleanupWorkspace(serverID, workspaceID string) { }) } - type KeyGenParams struct { Label string KeyType string @@ -90,15 +79,13 @@ type KeyGenParams struct { Comment string } - - func GetLatestAgentVersion() (string, error) { giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { giteaHost = "gitea.example.com" } url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost) - resp, err := http.Get(url) + resp, err := http.Get(url) if err != nil { return "", fmt.Errorf("fetch releases: %w", err) } @@ -122,8 +109,6 @@ func GetLatestAgentVersion() (string, error) { return "", fmt.Errorf("no agent release found") } - - func DispatchUpdateAgent(serverID string) (string, error) { if !Dispatcher.IsConnected(serverID) { return "", fmt.Errorf("agent is not connected to the command stream") @@ -153,7 +138,6 @@ func DispatchUpdateAgent(serverID string) (string, error) { return version, nil } - func DispatchApplyUpdates(serverID string) error { if !Dispatcher.IsConnected(serverID) { return fmt.Errorf("agent is not connected to the command stream") @@ -165,8 +149,6 @@ func DispatchApplyUpdates(serverID string) error { return Dispatcher.dispatch(serverID, cmd) } - - func DispatchDeleteKey(serverID, label string) { if !Dispatcher.IsConnected(serverID) { return @@ -176,13 +158,11 @@ func DispatchDeleteKey(serverID, label string) { DeleteKey: &pb.DeleteKeyCmd{Label: label}, } if err := Dispatcher.dispatch(serverID, cmd); err != nil { - + _ = err } } - - func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) { if !Dispatcher.IsConnected(serverID) { return "", fmt.Errorf("agent is not connected to the command stream") diff --git a/server/internal/services/org_oidc.go b/server/internal/services/instance_oidc.go similarity index 61% rename from server/internal/services/org_oidc.go rename to server/internal/services/instance_oidc.go index d5843ec..7c3a358 100644 --- a/server/internal/services/org_oidc.go +++ b/server/internal/services/instance_oidc.go @@ -10,32 +10,30 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) { +func GetInstanceOIDC(instanceID string) (*models.OrgOIDC, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var o models.OrgOIDC - err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o) if err != nil { return nil, err } return &o, nil } -func GetOrgOIDCSecret(orgID string) (string, error) { - o, err := GetOrgOIDC(orgID) +func GetInstanceOIDCSecret(instanceID string) (string, error) { + o, err := GetInstanceOIDC(instanceID) if err != nil { return "", err } return decryptString(o.ClientSecretEnc) } - - -func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error { +func SaveOrgOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() set := bson.M{ - "org_id": orgID, "issuer": issuer, "client_id": clientID, + "instance_id": instanceID, "issuer": issuer, "client_id": clientID, "enabled": enabled, "updated_at": time.Now(), } if clientSecret != "" { @@ -45,8 +43,8 @@ func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) err } set["client_secret_enc"] = enc } - _, err := db.Col("org_oidc").UpdateOne(ctx, - bson.M{"org_id": orgID}, bson.M{"$set": set}, + _, err := db.Col("instance_oidc").UpdateOne(ctx, + bson.M{"instance_id": instanceID}, bson.M{"$set": set}, options.UpdateOne().SetUpsert(true)) return err } diff --git a/server/internal/services/instances.go b/server/internal/services/instances.go new file mode 100644 index 0000000..c66f3eb --- /dev/null +++ b/server/internal/services/instances.go @@ -0,0 +1,122 @@ +package services + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "github.com/mrhid6/vantage/shared/provision" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +func GetInstance(instanceID string) (*models.Instance, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Instance + err := db.Col("instances").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func GetInstanceBySlug(slug string) (*models.Instance, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Instance + err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func ListInstanceIDs() ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cursor, err := db.Col("instances").Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var orgs []models.Instance + if err := cursor.All(ctx, &orgs); err != nil { + return nil, err + } + ids := make([]string, 0, len(orgs)) + for _, o := range orgs { + ids = append(ids, o.InstanceID) + } + return ids, nil +} + +func CountInstances() (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Col("instances").CountDocuments(ctx, bson.M{}) +} + +func FirstInstance() (*models.Instance, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.Instance + if err := db.Col("instances").FindOne(ctx, bson.M{}).Decode(&o); err != nil { + return nil, err + } + return &o, nil +} + +func AdoptInstance(instanceID, name string) (*models.Instance, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + set := bson.M{"name": name} + + slug := provision.Slugify(name) + if len(slug) > provision.MaxSlugLength { + slug = slug[:provision.MaxSlugLength] + } + if len(slug) >= provision.MinSlugLength && !provision.ReservedSlugs[slug] { + n, err := db.Col("instances").CountDocuments(ctx, bson.M{"slug": slug, "instance_id": bson.M{"$ne": instanceID}}) + if err != nil { + return nil, err + } + if n == 0 { + set["slug"] = slug + } + } + + if _, err := db.Col("instances").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("organization slug already taken") + } + return nil, err + } + return GetInstance(instanceID) +} + +// CreateInstance creates an organisation and seeds its default workflow steps. +// +// The creation rules live in shared/provision because sitesvc creates +// organisations too. Seeding stays here: shared must not know about workflow +// steps. +func CreateInstance(name string) (*models.Instance, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + o, err := provision.CreateInstance(ctx, db.Database, name) + if err != nil { + return nil, err + } + + if created, updated, err := SeedDefaultSteps(o.InstanceID); err != nil { + log.Printf("warning: failed to seed default steps for new org %s: %v", o.InstanceID, err) + } else { + log.Printf("default steps seeded for new org %s: %d created, %d updated", o.InstanceID, created, updated) + } + return o, nil +} diff --git a/server/internal/services/inventory.go b/server/internal/services/inventory.go index 29c9ba9..5609cba 100644 --- a/server/internal/services/inventory.go +++ b/server/internal/services/inventory.go @@ -9,8 +9,6 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) - - func StoreInventory(serverID string, r *pb.InventoryReport) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index d0caeac..37f3a81 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -36,9 +36,9 @@ func setKeyMeta(k *models.Key) { k.HasPassphrase = k.PassphraseEncrypted != "" } -func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) { +func CreateKey(instanceID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) { key := &models.Key{ - OrgID: orgID, + InstanceID: instanceID, KeyID: uuid.NewString(), Label: label, PublicKey: publicKey, @@ -72,12 +72,12 @@ func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, return key, nil } -func GetKey(orgID, keyID string) (*models.Key, error) { +func GetKey(instanceID, keyID string) (*models.Key, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key) + err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key) if err != nil { return nil, err } @@ -85,12 +85,12 @@ func GetKey(orgID, keyID string) (*models.Key, error) { return &key, nil } -func GetPrivateKey(orgID, keyID string) (string, error) { +func GetPrivateKey(instanceID, keyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil { return "", err } if key.PrivateKeyEncrypted == "" { @@ -118,11 +118,11 @@ type KeyWithCount struct { AssignedCount int `bson:"-" json:"assigned_count"` } -func ListKeys(orgID string) ([]KeyWithCount, error) { +func ListKeys(instanceID string) ([]KeyWithCount, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("keys").Find(ctx, bson.M{"org_id": orgID}) + cursor, err := db.Col("keys").Find(ctx, bson.M{"instance_id": instanceID}) if err != nil { return nil, err } @@ -137,28 +137,28 @@ func ListKeys(orgID string) ([]KeyWithCount, error) { for _, k := range keys { setKeyMeta(&k) count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{ - "org_id": orgID, - "key_id": k.KeyID, - "revoked_at": nil, + "instance_id": instanceID, + "key_id": k.KeyID, + "revoked_at": nil, }) result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)}) } return result, nil } -func DeleteKey(orgID, keyID string) error { +func DeleteKey(instanceID, keyID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil { return err } - if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil { + if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil { return err } - if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil { + if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil { return err } @@ -168,31 +168,30 @@ func DeleteKey(orgID, keyID string) error { return nil } -func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { +func AssignKey(instanceID, keyID, serverID string) (*models.Assignment, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if _, err := GetKey(orgID, keyID); err != nil { + if _, err := GetKey(instanceID, keyID); err != nil { return nil, fmt.Errorf("key not found") } - if _, err := GetServer(orgID, serverID); err != nil { + if _, err := GetServer(instanceID, serverID); err != nil { return nil, fmt.Errorf("server not found") } - var existing models.Assignment err := db.Col("assignments").FindOne(ctx, bson.M{ - "org_id": orgID, - "key_id": keyID, - "server_id": serverID, - "revoked_at": nil, + "instance_id": instanceID, + "key_id": keyID, + "server_id": serverID, + "revoked_at": nil, }).Decode(&existing) if err == nil { return &existing, nil } a := &models.Assignment{ - OrgID: orgID, + InstanceID: instanceID, KeyID: keyID, ServerID: serverID, AssignedAt: time.Now(), @@ -204,23 +203,23 @@ func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { return a, nil } -func RevokeAssignment(orgID, keyID, serverID string) error { +func RevokeAssignment(instanceID, keyID, serverID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() _, err := db.Col("assignments").UpdateOne(ctx, - bson.M{"org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil}, + bson.M{"instance_id": instanceID, "key_id": keyID, "server_id": serverID, "revoked_at": nil}, bson.M{"$set": bson.M{"revoked_at": now}}, ) return err } -func GetAssignmentsForKey(orgID, keyID string) ([]models.Assignment, error) { +func GetAssignmentsForKey(instanceID, keyID string) ([]models.Assignment, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID, "revoked_at": nil}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID, "revoked_at": nil}) if err != nil { return nil, err } @@ -238,11 +237,11 @@ type AssignmentWithServer struct { Server *models.Server `json:"server,omitempty"` } -func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, error) { +func GetAssignmentsWithServers(instanceID, keyID string) ([]AssignmentWithServer, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID}) if err != nil { return nil, err } @@ -257,7 +256,7 @@ func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, err for _, a := range assignments { item := AssignmentWithServer{Assignment: a} var srv models.Server - if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "org_id": orgID}).Decode(&srv); err == nil { + if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "instance_id": instanceID}).Decode(&srv); err == nil { item.Server = &srv } result = append(result, item) @@ -270,11 +269,11 @@ type AssignmentWithKey struct { Key *models.Key `json:"key,omitempty"` } -func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKey, error) { +func GetAssignmentsWithKeysForServer(instanceID, serverID string) ([]AssignmentWithKey, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "server_id": serverID}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "server_id": serverID}) if err != nil { return nil, err } @@ -288,7 +287,7 @@ func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKe result := make([]AssignmentWithKey, 0, len(assignments)) for _, a := range assignments { var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": orgID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": instanceID}).Decode(&key); err != nil { continue } setKeyMeta(&key) diff --git a/server/internal/services/migrate.go b/server/internal/services/migrate.go index 061a8e9..78c0531 100644 --- a/server/internal/services/migrate.go +++ b/server/internal/services/migrate.go @@ -8,51 +8,36 @@ import ( "github.com/google/uuid" "github.com/mrhid6/vantage/server/internal/db" - "github.com/mrhid6/vantage/server/internal/models" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" ) -var scopedCollections = []string{ +// legacyOrg is the pre-0004 shape of the orgs collection. +// +// Migrations 0001 to 0003 run BEFORE the org-to-instance rename and must keep +// reading and writing org_id in the orgs collection. They deliberately do not +// use shared/models, which has moved on to Instance and instance_id. +type legacyOrg struct { + OrgID string `bson:"org_id"` + Name string `bson:"name"` + Slug string `bson:"slug"` + CreatedAt time.Time `bson:"created_at"` +} + +var backfillCollections = []string{ "servers", "keys", "assignments", "secrets", "workflows", "workflow_steps", "workflow_runs", "audit_logs", "monitors", "notification_channels", "console_sessions", "incidents", "monitor_rollups", } -func EnsureAuthIndexes() error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "email", Value: 1}}, - Options: options.Index().SetUnique(true), - }); err != nil { - return err - } - if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "slug", Value: 1}}, - Options: options.Index().SetUnique(true), - }); err != nil { - return err - } - if _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "org_id", Value: 1}}, - Options: options.Index().SetUnique(true), - }); err != nil { - return err - } - return nil -} - -func defaultBackfillOrg(ctx context.Context) (*models.Org, error) { - var org models.Org +func defaultBackfillOrg(ctx context.Context) (*legacyOrg, error) { + var org legacyOrg err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org) switch { case err == nil: case errors.Is(err, mongo.ErrNoDocuments): - org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} + org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil { return nil, err } @@ -71,9 +56,8 @@ func RunMigrations() error { return nil } - needs := false - for _, col := range scopedCollections { + for _, col := range backfillCollections { n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) if n > 0 { needs = true @@ -86,7 +70,7 @@ func RunMigrations() error { if err != nil { return err } - for _, col := range scopedCollections { + for _, col := range backfillCollections { if _, err := db.Col(col).UpdateMany(ctx, bson.M{"org_id": bson.M{"$exists": false}}, bson.M{"$set": bson.M{"org_id": org.OrgID}}, @@ -109,7 +93,6 @@ func MigrateMissedOrgScopes() error { return nil } - missed := []string{"audit_logs", "notification_channels"} needs := false for _, col := range missed { @@ -190,7 +173,7 @@ func MigrateSettingsOrg() error { n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}}) if n > 0 { - var org models.Org + var org legacyOrg orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{}) if err != nil { return err @@ -201,7 +184,7 @@ func MigrateSettingsOrg() error { return err } case 0: - org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} + org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()} if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil { return err } diff --git a/server/internal/services/migrate_instance.go b/server/internal/services/migrate_instance.go new file mode 100644 index 0000000..0aff678 --- /dev/null +++ b/server/internal/services/migrate_instance.go @@ -0,0 +1,239 @@ +package services + +import ( + "context" + "fmt" + "log" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ScopedCollections lists every collection carrying the tenant key. +// +// Migration 0004 renames org_id to instance_id in each. A collection missing +// from this list keeps the old field name and becomes invisible to every scoped +// query — so this list is load-bearing, not documentation. +// +// AssertNoScopedCollectionMissed checks at boot that nothing outside this list +// holds an org_id. +// +// The migrations collection is deliberately absent: it is not tenant-scoped. +// The two renamed collections appear under their post-rename names, because the +// migration renames the collections before it renames the field. +var ScopedCollections = []string{ + "instances", + "servers", + "keys", + "assignments", + "users", + "instance_oidc", + "settings", + "secrets", + "workflows", + "workflow_steps", + "workflow_runs", + "monitors", + "incidents", + "monitor_rollups", + "notification_channels", + "console_sessions", + "audit_logs", +} + +// collectionRenames maps the two collections whose names change. Ordered so the +// migration is deterministic. +var collectionRenames = []struct{ from, to string }{ + {"orgs", "instances"}, + {"org_oidc", "instance_oidc"}, +} + +// MigrateOrgToInstance renames the tenant key from org_id to instance_id. +// +// It only ever renames documents. It never deletes, drops or unsets one, so a +// bad deploy is recovered by running the inverse rename (cmd/rename-rollback) +// rather than by restoring a backup. +// +// The steps are not atomic across collections — multi-document transactions +// would require a replica set, which self-hosted installs do not guarantee. +// Instead every step is safely repeatable: a collection rename is skipped when +// the source is already gone, and $rename matches nothing on a document that +// has already been renamed. A run that fails partway is fixed by running it +// again. +// +// It must run BEFORE EnsureAuthIndexes. Creating the instances.slug index first +// would create an empty instances collection, and step 1 below refuses to +// rename orgs onto an existing target. +func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error { + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + exists := map[string]bool{} + for _, n := range names { + exists[n] = true + } + + // Step 1: rename the collections. + for _, r := range collectionRenames { + switch { + case !exists[r.from]: + // Nothing to rename: either already done or never existed. + continue + case exists[r.to]: + return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to) + } + cmd := bson.D{ + {Key: "renameCollection", Value: db.Name() + "." + r.from}, + {Key: "to", Value: db.Name() + "." + r.to}, + } + if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil { + return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err) + } + log.Printf("0004: renamed collection %s to %s", r.from, r.to) + } + + // Step 2: drop indexes keyed on the old field name, BEFORE renaming it. + // + // Order matters and is not obvious. A unique index on org_id treats a + // missing org_id as null, so as soon as $rename strips the field from the + // second document the index reports a duplicate null and the whole update + // fails. Dropping first avoids that entirely. + // + // Dropping an index touches no documents. The boot-time index builders + // recreate the current ones against the new field name. + for _, c := range ScopedCollections { + if err := DropIndexesKeyedOn(ctx, db, c, "org_id"); err != nil { + return err + } + } + + // Step 3: rename the field. + for _, c := range ScopedCollections { + res, err := db.Collection(c).UpdateMany(ctx, + bson.M{"org_id": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"org_id": "instance_id"}}, + ) + if err != nil { + return fmt.Errorf("rename org_id in %s: %w", c, err) + } + if res.ModifiedCount > 0 { + log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount) + } + } + + // Step 4: verify before anyone records a marker. Any mismatch aborts, and + // the migration is re-run rather than marked done. + for _, c := range ScopedCollections { + total, err := db.Collection(c).CountDocuments(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("count %s: %w", c, err) + } + if total == 0 { + continue + } + + stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count stale in %s: %w", c, err) + } + if stale != 0 { + return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale) + } + + scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count scoped in %s: %w", c, err) + } + if scoped != total { + return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped) + } + } + + log.Printf("0004: verified %d collection(s)", len(ScopedCollections)) + return nil +} + +// IndexKeyedOn reports whether an index specification's key document mentions +// field. +// +// The key is checked as both bson.D and bson.M because the driver's decoding of +// a nested document depends on the target type, and getting this wrong is +// silent: the index simply is not found, and the field rename then fails on a +// duplicate null. +func IndexKeyedOn(key any, field string) bool { + switch k := key.(type) { + case bson.M: + _, ok := k[field] + return ok + case bson.D: + for _, e := range k { + if e.Key == field { + return true + } + } + } + return false +} + +// DropIndexesKeyedOn removes every index on coll whose key mentions field, +// leaving _id_ alone. +// +// Both the migration and its rollback must do this BEFORE renaming the field. A +// unique index treats a missing field as null, so as soon as $rename strips the +// field from the second document the index reports a duplicate null and the +// whole update fails. Dropping an index touches no documents; the boot-time +// index builders recreate what is needed. +func DropIndexesKeyedOn(ctx context.Context, db *mongo.Database, coll, field string) error { + cur, err := db.Collection(coll).Indexes().List(ctx) + if err != nil { + return fmt.Errorf("list indexes on %s: %w", coll, err) + } + var specs []bson.M + if err := cur.All(ctx, &specs); err != nil { + return fmt.Errorf("decode indexes on %s: %w", coll, err) + } + for _, s := range specs { + name, _ := s["name"].(string) + if name == "_id_" { + continue + } + if !IndexKeyedOn(s["key"], field) { + continue + } + if err := db.Collection(coll).Indexes().DropOne(ctx, name); err != nil { + return fmt.Errorf("drop index %s on %s: %w", name, coll, err) + } + log.Printf("dropped stale index %s on %s", name, coll) + } + return nil +} + +// AssertNoScopedCollectionMissed reports any collection holding an org_id that +// ScopedCollections does not know about. A hit means a collection was added +// without being added to the list, and its tenant key was never renamed. +func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error { + known := map[string]bool{} + for _, c := range ScopedCollections { + known[c] = true + } + + names, err := db.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + for _, n := range names { + if known[n] { + continue + } + count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}}) + if err != nil { + return fmt.Errorf("count %s: %w", n, err) + } + if count > 0 { + return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count) + } + } + return nil +} diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index 4cbfc5b..e5569b9 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -21,7 +21,6 @@ func monCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 5*time.Second) } - func SpecFor(m *models.Monitor) checker.Spec { return checker.Spec{ Type: m.Type, @@ -37,10 +36,10 @@ func SpecFor(m *models.Monitor) checker.Spec { } } -func ListMonitors(orgID string) ([]models.Monitor, error) { +func ListMonitors(instanceID string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("monitors").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1})) + cur, err := db.Col("monitors").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1})) if err != nil { return nil, err } @@ -51,23 +50,23 @@ func ListMonitors(orgID string) ([]models.Monitor, error) { return out, nil } -func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { - if orgID == "" { +func ListMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) { + if instanceID == "" { return nil, errors.New("org id required") } - return listMonitorsForRunner(orgID, runner) + return listMonitorsForRunner(instanceID, runner) } func ListServerScheduledMonitors() ([]models.Monitor, error) { return listMonitorsForRunner("", models.RunnerServer) } -func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { +func listMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() filter := bson.M{"runner": runner, "enabled": true} - if orgID != "" { - filter["org_id"] = orgID + if instanceID != "" { + filter["instance_id"] = instanceID } cur, err := db.Col("monitors").Find(ctx, filter) if err != nil { @@ -80,12 +79,11 @@ func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) { return out, nil } - -func GetMonitor(orgID, monitorID string) (*models.Monitor, error) { +func GetMonitor(instanceID, monitorID string) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() var m models.Monitor - err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}).Decode(&m) + err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}).Decode(&m) if errors.Is(err, mongo.ErrNoDocuments) { return nil, nil } @@ -109,26 +107,26 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) { return &m, nil } -func validateRunner(orgID, runner string) error { +func validateRunner(instanceID, runner string) error { if runner == "" || runner == models.RunnerServer { return nil } - if _, err := GetServer(orgID, runner); err != nil { + if _, err := GetServer(instanceID, runner); err != nil { return fmt.Errorf("runner server %s not found", runner) } return nil } -func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { +func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() - if err := validateChannelIDs(orgID, m.ChannelIDs); err != nil { + if err := validateChannelIDs(instanceID, m.ChannelIDs); err != nil { return nil, err } - if err := validateRunner(orgID, m.Runner); err != nil { + if err := validateRunner(instanceID, m.Runner); err != nil { return nil, err } - m.OrgID = orgID + m.InstanceID = instanceID m.MonitorID = uuid.NewString() m.CreatedAt = time.Now() if m.IntervalSec <= 0 { @@ -147,17 +145,16 @@ func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { return m, nil } -func UpdateMonitor(orgID, monitorID string, upd bson.M) error { +func UpdateMonitor(instanceID, monitorID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - - + if raw, present := upd["channel_ids"]; present { ids, ok := raw.([]string) if !ok { return fmt.Errorf("channel_ids must be a string array") } - if err := validateChannelIDs(orgID, ids); err != nil { + if err := validateChannelIDs(instanceID, ids); err != nil { return err } } @@ -166,43 +163,41 @@ func UpdateMonitor(orgID, monitorID string, upd bson.M) error { if !ok { return fmt.Errorf("runner must be a string") } - if err := validateRunner(orgID, runner); err != nil { + if err := validateRunner(instanceID, runner); err != nil { return err } - - - + if runner == "" { upd["runner"] = models.RunnerServer } } - _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd}) + _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}, bson.M{"$set": upd}) return err } -func DeleteMonitor(orgID, monitorID string) error { +func DeleteMonitor(instanceID, monitorID string) error { ctx, cancel := monCtx() defer cancel() - res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}) + res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}) if err != nil { return err } - + if res.DeletedCount == 0 { return nil } - db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}) - db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}) + db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}) + db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}) return nil } -func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, error) { +func ListIncidents(instanceID, monitorID string, limit int64) ([]models.Incident, error) { ctx, cancel := monCtx() defer cancel() if limit <= 0 { limit = 50 } - cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, + cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}, options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit)) if err != nil { return nil, err @@ -214,12 +209,11 @@ func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, err return out, nil } - -func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, error) { +func UptimeRollups(instanceID, monitorID string, since time.Time) ([]models.Rollup, error) { ctx, cancel := monCtx() defer cancel() cur, err := db.Col("monitor_rollups").Find(ctx, - bson.M{"monitor_id": monitorID, "org_id": orgID, "period_start": bson.M{"$gte": since}}, + bson.M{"monitor_id": monitorID, "instance_id": instanceID, "period_start": bson.M{"$gte": since}}, options.Find().SetSort(bson.M{"period_start": 1})) if err != nil { return nil, err @@ -231,18 +225,18 @@ func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, e return out, nil } -func IngestResult(orgID, runner, monitorID string, res checker.Result) error { - if orgID == "" { +func IngestResult(instanceID, runner, monitorID string, res checker.Result) error { + if instanceID == "" { return errors.New("org id required") } - return ingestResult(orgID, runner, monitorID, res) + return ingestResult(instanceID, runner, monitorID, res) } func IngestServerScheduledResult(monitorID string, res checker.Result) error { return ingestResult("", models.RunnerServer, monitorID, res) } -func ingestResult(orgID, runner, monitorID string, res checker.Result) error { +func ingestResult(instanceID, runner, monitorID string, res checker.Result) error { ctx, cancel := monCtx() defer cancel() @@ -253,7 +247,7 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { if m == nil { return fmt.Errorf("monitor %s not found", monitorID) } - if orgID != "" && m.OrgID != orgID { + if instanceID != "" && m.InstanceID != instanceID { return fmt.Errorf("monitor %s belongs to another org", monitorID) } if m.Runner != runner { @@ -295,28 +289,25 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { return err } - bucket := now.Truncate(time.Hour) up := 0 if res.Up { up = 1 } - - + db.Col("monitor_rollups").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "period_start": bucket}, bson.M{ "$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)}, - "$setOnInsert": bson.M{"org_id": m.OrgID}, + "$setOnInsert": bson.M{"instance_id": m.InstanceID}, }, options.UpdateOne().SetUpsert(true)) - if newStatus != prev { switch newStatus { case models.StatusDown: inc := models.Incident{ - OrgID: m.OrgID, + InstanceID: m.InstanceID, IncidentID: uuid.NewString(), MonitorID: monitorID, StartedAt: now, @@ -327,7 +318,7 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { case models.StatusUp: if prev == models.StatusDown { db.Col("incidents").UpdateOne(ctx, - bson.M{"monitor_id": monitorID, "org_id": m.OrgID, "resolved_at": nil}, + bson.M{"monitor_id": monitorID, "instance_id": m.InstanceID, "resolved_at": nil}, bson.M{"$set": bson.M{"resolved_at": now}}) notifyTransition(m, newStatus, res.Message) } @@ -336,14 +327,11 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error { return nil } - - - func notifyTransition(m *models.Monitor, newStatus, message string) { if len(m.ChannelIDs) == 0 { return } - channels, err := GetChannels(m.OrgID, m.ChannelIDs) + channels, err := GetChannels(m.InstanceID, m.ChannelIDs) if err != nil { log.Printf("notify: load channels for %s: %v", m.MonitorID, err) return @@ -366,5 +354,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) { } }(ch) } - _ = UpdateMonitor(m.OrgID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()}) + _ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()}) } diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go deleted file mode 100644 index 49edfc2..0000000 --- a/server/internal/services/orgs.go +++ /dev/null @@ -1,157 +0,0 @@ -package services - -import ( - "context" - "fmt" - "log" - "time" - - "github.com/google/uuid" - "github.com/mrhid6/vantage/server/internal/db" - "github.com/mrhid6/vantage/server/internal/models" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -var reservedSlugs = map[string]bool{ - "www": true, "api": true, "app": true, "admin": true, "auth": true, - "install": true, "static": true, "_next": true, "default": true, -} - -func GetOrg(orgID string) (*models.Org, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - var o models.Org - err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) - if err != nil { - return nil, err - } - return &o, nil -} - -func GetOrgBySlug(slug string) (*models.Org, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - var o models.Org - err := db.Col("orgs").FindOne(ctx, bson.M{"slug": slug}).Decode(&o) - if err != nil { - return nil, err - } - return &o, nil -} - - - -func ListOrgIDs() ([]string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - cursor, err := db.Col("orgs").Find(ctx, bson.M{}) - if err != nil { - return nil, err - } - defer cursor.Close(ctx) - var orgs []models.Org - if err := cursor.All(ctx, &orgs); err != nil { - return nil, err - } - ids := make([]string, 0, len(orgs)) - for _, o := range orgs { - ids = append(ids, o.OrgID) - } - return ids, nil -} - - - - -func CountOrgs() (int64, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return db.Col("orgs").CountDocuments(ctx, bson.M{}) -} - -func FirstOrg() (*models.Org, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - var o models.Org - if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&o); err != nil { - return nil, err - } - return &o, nil -} - -func AdoptOrg(orgID, name string) (*models.Org, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - set := bson.M{"name": name} - - slug := Slugify(name) - if len(slug) > 40 { - slug = slug[:40] - } - if len(slug) >= 3 && !reservedSlugs[slug] { - n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug, "org_id": bson.M{"$ne": orgID}}) - if err != nil { - return nil, err - } - if n == 0 { - set["slug"] = slug - } - } - - if _, err := db.Col("orgs").UpdateOne(ctx, bson.M{"org_id": orgID}, bson.M{"$set": set}); err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, fmt.Errorf("organization slug already taken") - } - return nil, err - } - return GetOrg(orgID) -} - -func CreateOrg(name string) (*models.Org, error) { - base := Slugify(name) - if len(base) < 3 { - return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)") - } - if len(base) > 40 { - base = base[:40] - } - if reservedSlugs[base] { - return nil, fmt.Errorf("organization name is reserved") - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - - slug := base - for i := 2; ; i++ { - n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug}) - if err != nil { - return nil, err - } - if n == 0 { - break - } - slug = fmt.Sprintf("%s-%d", base, i) - } - - o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()} - if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, fmt.Errorf("organization slug already taken") - } - return nil, err - } - - - - - if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil { - log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err) - } else { - log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated) - } - return o, nil -} diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index 47fbd6d..5735832 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -23,7 +23,7 @@ func EnsureSecretIndexes() error { } _, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}}, + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}}, Options: options.Index().SetUnique(true), }) return err @@ -38,12 +38,12 @@ func isIndexNotFound(err error) bool { return false } -func ListSecretGroups(orgID string) ([]models.GroupSummary, error) { +func ListSecretGroups(instanceID string) ([]models.GroupSummary, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pipeline := mongo.Pipeline{ - {{Key: "$match", Value: bson.D{{Key: "org_id", Value: orgID}}}}, + {{Key: "$match", Value: bson.D{{Key: "instance_id", Value: instanceID}}}}, {{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$group"}, {Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}}, @@ -78,11 +78,11 @@ func ListSecretGroups(orgID string) ([]models.GroupSummary, error) { return groups, nil } -func GetSecretGroup(orgID, group string) ([]models.Secret, error) { +func GetSecretGroup(instanceID, group string) ([]models.Secret, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("secrets").Find(ctx, bson.M{"org_id": orgID, "group": group}, + cursor, err := db.Col("secrets").Find(ctx, bson.M{"instance_id": instanceID, "group": group}, options.Find().SetSort(bson.D{{Key: "key", Value: 1}})) if err != nil { return nil, err @@ -96,8 +96,8 @@ func GetSecretGroup(orgID, group string) ([]models.Secret, error) { return docs, nil } -func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { - docs, err := GetSecretGroup(orgID, group) +func GetSecretGroupDecrypted(instanceID, group string) (map[string]string, error) { + docs, err := GetSecretGroup(instanceID, group) if err != nil { return nil, err } @@ -112,12 +112,12 @@ func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { return result, nil } -func RevealSecret(orgID, group, key string) (string, error) { +func RevealSecret(instanceID, group, key string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var doc models.Secret - err := db.Col("secrets").FindOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}).Decode(&doc) + err := db.Col("secrets").FindOne(ctx, bson.M{"instance_id": instanceID, "group": group, "key": key}).Decode(&doc) if err == mongo.ErrNoDocuments { return "", fmt.Errorf("secret not found") } @@ -127,7 +127,7 @@ func RevealSecret(orgID, group, key string) (string, error) { return decryptString(doc.EncryptedValue) } -func UpsertSecrets(orgID, group string, values map[string]string) error { +func UpsertSecrets(instanceID, group string, values map[string]string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -137,9 +137,9 @@ func UpsertSecrets(orgID, group string, values map[string]string) error { return fmt.Errorf("encrypt %s: %w", key, err) } _, err = db.Col("secrets").UpdateOne(ctx, - bson.M{"org_id": orgID, "group": group, "key": key}, + bson.M{"instance_id": instanceID, "group": group, "key": key}, bson.M{"$set": bson.M{ - "org_id": orgID, + "instance_id": instanceID, "encrypted_value": encrypted, "updated_at": time.Now(), }}, @@ -152,7 +152,6 @@ func UpsertSecrets(orgID, group string, values map[string]string) error { return nil } - func SortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -162,20 +161,18 @@ func SortedKeys(m map[string]string) []string { return keys } - -func DeleteSecret(orgID, group, key string) error { +func DeleteSecret(instanceID, group, key string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("secrets").DeleteOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}) + _, err := db.Col("secrets").DeleteOne(ctx, bson.M{"instance_id": instanceID, "group": group, "key": key}) return err } - -func DeleteSecretGroup(orgID, group string) error { +func DeleteSecretGroup(instanceID, group string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("secrets").DeleteMany(ctx, bson.M{"org_id": orgID, "group": group}) + _, err := db.Col("secrets").DeleteMany(ctx, bson.M{"instance_id": instanceID, "group": group}) return err } diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 53f1094..a9d702b 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -30,14 +30,14 @@ func HashToken(token string) string { return hex.EncodeToString(sum[:]) } -func CreateServer(orgID string) (*models.Server, string, error) { +func CreateServer(instanceID string) (*models.Server, string, error) { token, err := generateToken(32) if err != nil { return nil, "", err } expires := time.Now().Add(time.Hour) s := &models.Server{ - OrgID: orgID, + InstanceID: instanceID, ServerID: uuid.NewString(), PreRegToken: token, PreRegExpires: &expires, @@ -54,21 +54,18 @@ func CreateServer(orgID string) (*models.Server, string, error) { return s, token, nil } - -func GetServer(orgID, serverID string) (*models.Server, error) { +func GetServer(instanceID, serverID string) (*models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var s models.Server - err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Decode(&s) + err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID}).Decode(&s) if err != nil { return nil, err } return &s, nil } - - func getServerByID(serverID string) (*models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -96,9 +93,6 @@ func GetServerByPreRegToken(token string) (*models.Server, error) { return &s, nil } - - - func OSTypeFromInfo(osInfo string) string { if strings.HasPrefix(strings.ToLower(osInfo), "windows") { return "windows" @@ -106,8 +100,6 @@ func OSTypeFromInfo(osInfo string) string { return "linux" } - - func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) { if osType == "windows" { return []string{"rdp"}, 22, 3389 @@ -181,19 +173,13 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) { if err != nil { return nil, fmt.Errorf("invalid agent token") } - - - if s.OrgID == "" { + + if s.InstanceID == "" { return nil, fmt.Errorf("server %s has no org", serverID) } return &s, nil } - - - - - func BackfillConsoleConfig(srv *models.Server) error { if srv == nil || len(srv.ConsoleProtocols) > 0 { return nil @@ -236,12 +222,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error { return err } -func ListServers(orgID string) ([]models.Server, error) { +func ListServers(instanceID string) ([]models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) - cursor, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, opts) + cursor, err := db.Col("servers").Find(ctx, bson.M{"instance_id": instanceID}, opts) if err != nil { return nil, err } @@ -254,16 +240,16 @@ func ListServers(orgID string) ([]models.Server, error) { return servers, nil } -func DeleteServer(orgID, serverID string) error { +func DeleteServer(instanceID, serverID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}) + _, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID}) if err != nil { return err } - - _, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "org_id": orgID}) + + _, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "instance_id": instanceID}) return err } @@ -283,42 +269,31 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error { } func MarkOfflineServers() error { - orgIDs, err := ListOrgIDs() + instanceIDs, err := ListInstanceIDs() if err != nil { return err } - - - - - for _, orgID := range orgIDs { - if err := markOfflineForFilter(bson.M{"org_id": orgID}, orgID); err != nil { - log.Printf("offline sweep failed for org %s: %v", orgID, err) + for _, instanceID := range instanceIDs { + if err := markOfflineForFilter(bson.M{"instance_id": instanceID}, instanceID); err != nil { + log.Printf("offline sweep failed for org %s: %v", instanceID, err) } } - - - - - if err := markOfflineForFilter(bson.M{"org_id": bson.M{"$nin": orgIDs}}, ""); err != nil { + if err := markOfflineForFilter(bson.M{"instance_id": bson.M{"$nin": instanceIDs}}, ""); err != nil { log.Printf("offline sweep failed for orphaned servers: %v", err) } return nil } - - - -func markOfflineForFilter(scope bson.M, orgID string) error { +func markOfflineForFilter(scope bson.M, instanceID string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() var settings *models.Settings thresholdMinutes := 5 - if orgID != "" { - settings, _ = GetSettings(orgID) + if instanceID != "" { + settings, _ = GetSettings(instanceID) if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 { thresholdMinutes = settings.Alerts.OfflineThresholdMinutes } @@ -333,7 +308,6 @@ func markOfflineForFilter(scope bson.M, orgID string) error { filter[k] = v } - cursor, err := db.Col("servers").Find(ctx, filter) if err != nil { return err @@ -349,7 +323,7 @@ func markOfflineForFilter(scope bson.M, orgID string) error { } for _, s := range goingOffline { - LogEvent(s.OrgID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress)) + LogEvent(s.InstanceID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress)) if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" { go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress) } diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index fcaae37..2be0661 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -34,9 +34,6 @@ var defaultSettings = models.Settings{ }, } - - - func EnsureSettingsIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -46,16 +43,12 @@ func EnsureSettingsIndexes() error { } if _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "org_id", Value: 1}}, + Keys: bson.D{{Key: "instance_id", Value: 1}}, Options: options.Index().SetUnique(true), }); err != nil { return err } - - - - _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "secrets.read_token_hash", Value: 1}}, Options: options.Index().SetUnique(true).SetName("settings_read_token_hash_unique"). @@ -66,15 +59,15 @@ func EnsureSettingsIndexes() error { return err } -func GetSettings(orgID string) (*models.Settings, error) { +func GetSettings(instanceID string) (*models.Settings, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var s models.Settings - err := db.Col("settings").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&s) + err := db.Col("settings").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&s) if err == mongo.ErrNoDocuments { cp := defaultSettings - cp.OrgID = orgID + cp.InstanceID = instanceID return &cp, nil } if err != nil { @@ -89,9 +82,7 @@ func hashToken(token string) string { return hex.EncodeToString(sum[:]) } - - -func RotateSecretsReadToken(orgID string) (string, error) { +func RotateSecretsReadToken(instanceID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -102,13 +93,13 @@ func RotateSecretsReadToken(orgID string) (string, error) { token := hex.EncodeToString(raw) _, err := db.Col("settings").UpdateOne(ctx, - bson.M{"org_id": orgID}, + bson.M{"instance_id": instanceID}, bson.M{ "$set": bson.M{ "secrets.read_token_hash": hashToken(token), "secrets.rotated_at": time.Now(), }, - "$setOnInsert": bson.M{"org_id": orgID}, + "$setOnInsert": bson.M{"instance_id": instanceID}, }, options.UpdateOne().SetUpsert(true), ) @@ -118,9 +109,6 @@ func RotateSecretsReadToken(orgID string) (string, error) { return token, nil } - - - func ResolveSecretsReadToken(token string) (string, bool) { if token == "" { return "", false @@ -130,7 +118,7 @@ func ResolveSecretsReadToken(token string) (string, bool) { var s models.Settings err := db.Col("settings").FindOne(ctx, bson.M{"secrets.read_token_hash": hashToken(token)}).Decode(&s) - if err != nil || s.Secrets.ReadTokenHash == "" || s.OrgID == "" { + if err != nil || s.Secrets.ReadTokenHash == "" || s.InstanceID == "" { return "", false } expected, err := hex.DecodeString(s.Secrets.ReadTokenHash) @@ -141,10 +129,10 @@ func ResolveSecretsReadToken(token string) (string, bool) { if subtle.ConstantTimeCompare(expected, got[:]) != 1 { return "", false } - return s.OrgID, true + return s.InstanceID, true } -func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error { +func SaveSettings(instanceID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -160,17 +148,15 @@ func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailS set["workflow_log_retention_days"] = *retentionDays } _, err := db.Col("settings").UpdateOne(ctx, - bson.M{"org_id": orgID}, - bson.M{"$set": set, "$setOnInsert": bson.M{"org_id": orgID}}, + bson.M{"instance_id": instanceID}, + bson.M{"$set": set, "$setOnInsert": bson.M{"instance_id": instanceID}}, options.UpdateOne().SetUpsert(true), ) return err } - - -func GetWorkflowLogRetentionDays(orgID string) (int, error) { - s, err := GetSettings(orgID) +func GetWorkflowLogRetentionDays(instanceID string) (int, error) { + s, err := GetSettings(instanceID) if err != nil { return 30, err } @@ -241,7 +227,6 @@ func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress st } } - func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error { conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) if err != nil { @@ -278,4 +263,3 @@ func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, ms } return c.Quit() } - diff --git a/server/internal/services/stepio.go b/server/internal/services/stepio.go index 6f2e50d..1339ae8 100644 --- a/server/internal/services/stepio.go +++ b/server/internal/services/stepio.go @@ -9,7 +9,6 @@ import ( const StepDocKind = "vantage.step/v1" - type StepDoc struct { Kind string `json:"kind"` Name string `json:"name"` @@ -21,7 +20,6 @@ type StepDoc struct { SecretRefs []string `json:"secret_refs"` } - func ExportStepDoc(s models.WorkflowStep) StepDoc { return StepDoc{ Kind: StepDocKind, @@ -35,8 +33,6 @@ func ExportStepDoc(s models.WorkflowStep) StepDoc { } } - - func ParseStepDoc(b []byte) (models.WorkflowStep, error) { var d StepDoc if err := json.Unmarshal(b, &d); err != nil { @@ -65,20 +61,18 @@ func ParseStepDoc(b []byte) (models.WorkflowStep, error) { }, nil } - -func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) { +func ImportStepToLibrary(instanceID string, b []byte) (*models.WorkflowStep, error) { s, err := ParseStepDoc(b) if err != nil { return nil, err } - return CreateStep(orgID, s) + return CreateStep(instanceID, s) } - -func ExportStep(orgID, stepID string) ([]byte, error) { +func ExportStep(instanceID, stepID string) ([]byte, error) { ctx, cancel := wfCtx() defer cancel() - s, err := getStep(ctx, orgID, stepID) + s, err := getStep(ctx, instanceID, stepID) if err != nil { return nil, err } diff --git a/server/internal/services/steplogs.go b/server/internal/services/steplogs.go index 84b6714..36893e1 100644 --- a/server/internal/services/steplogs.go +++ b/server/internal/services/steplogs.go @@ -14,7 +14,6 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo" ) - func WorkflowLogDir() string { dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR") if dir == "" { @@ -24,19 +23,14 @@ func WorkflowLogDir() string { return dir } - func ServerRunLogPath(runID, serverID string) string { return filepath.Join(WorkflowLogDir(), runID, serverID+".log") } - - func logTS() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z" } - - func AppendMarker(runID, serverID, text string) (int64, error) { path := ServerRunLogPath(runID, serverID) if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { @@ -47,19 +41,17 @@ func AppendMarker(runID, serverID, text string) (int64, error) { return 0, err } defer f.Close() - off, _ := f.Seek(0, 2) + off, _ := f.Seek(0, 2) if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil { return off, err } return off, nil } - - type stepLogWriter struct { mu sync.Mutex f *os.File - carry []byte + carry []byte secrets []string } @@ -70,7 +62,6 @@ type stepLogRegistry struct { var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)} - func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err @@ -92,10 +83,6 @@ func (r *stepLogRegistry) get(commandID string) *stepLogWriter { return r.writers[commandID] } - - - - func (r *stepLogRegistry) Append(commandID string, data []byte) { w := r.get(commandID) if w == nil { @@ -115,7 +102,6 @@ func (r *stepLogRegistry) Append(commandID string, data []byte) { w.carry = append([]byte{}, buf...) } - func (w *stepLogWriter) writeLine(line []byte) { masked := maskBytes(line, w.secrets) _, _ = w.f.WriteString("[" + logTS() + "] ") @@ -123,7 +109,6 @@ func (w *stepLogWriter) writeLine(line []byte) { _, _ = w.f.WriteString("\n") } - func (r *stepLogRegistry) Close(commandID string) { r.mu.Lock() w := r.writers[commandID] @@ -152,9 +137,6 @@ func maskBytes(b []byte, secrets []string) []byte { return []byte(s) } - - - func StartLogSweeper() { go func() { sweepLogs() @@ -166,10 +148,6 @@ func StartLogSweeper() { }() } - - - - func sweepLogs() { base := WorkflowLogDir() entries, err := os.ReadDir(base) @@ -186,30 +164,28 @@ func sweepLogs() { runID := e.Name() dir := filepath.Join(base, runID) - orgID, finishedAt, found, err := runRetentionInfo(runID) + instanceID, finishedAt, found, err := runRetentionInfo(runID) if err != nil { - - - + log.Printf("log sweep: retention lookup failed for run %s: %v", runID, err) continue } if found && finishedAt == nil { - continue + continue } - days, ok := cache[orgID] + days, ok := cache[instanceID] if !ok { days = defaultRetentionDays - if orgID != "" { - if v, err := GetWorkflowLogRetentionDays(orgID); err == nil { + if instanceID != "" { + if v, err := GetWorkflowLogRetentionDays(instanceID); err == nil { days = v } } - cache[orgID] = days + cache[instanceID] = days } if days <= 0 { - continue + continue } cutoff := now.AddDate(0, 0, -days) @@ -219,7 +195,7 @@ func sweepLogs() { } continue } - + if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) { _ = os.RemoveAll(dir) } @@ -228,14 +204,11 @@ func sweepLogs() { const defaultRetentionDays = 30 - - - func runRetentionInfo(runID string) (string, *time.Time, bool, error) { ctx, cancel := wfCtx() defer cancel() var run struct { - OrgID string `bson:"org_id"` + InstanceID string `bson:"instance_id"` FinishedAt *time.Time `bson:"finished_at"` } err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run) @@ -245,5 +218,5 @@ func runRetentionInfo(runID string) (string, *time.Time, bool, error) { if err != nil { return "", nil, false, err } - return run.OrgID, run.FinishedAt, true, nil + return run.InstanceID, run.FinishedAt, true, nil } diff --git a/server/internal/services/stepresults.go b/server/internal/services/stepresults.go index 4b9dd11..464911f 100644 --- a/server/internal/services/stepresults.go +++ b/server/internal/services/stepresults.go @@ -11,12 +11,8 @@ type stepResultRegistry struct { pending map[string]chan *pb.StepResult } - - var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)} - - func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult { ch := make(chan *pb.StepResult, 1) r.mu.Lock() @@ -25,14 +21,12 @@ func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult { return ch } - func (r *stepResultRegistry) Cancel(commandID string) { r.mu.Lock() delete(r.pending, commandID) r.mu.Unlock() } - func (r *stepResultRegistry) Deliver(res *pb.StepResult) { if res == nil { return diff --git a/server/internal/services/stepscan.go b/server/internal/services/stepscan.go index 9fb3828..1d87595 100644 --- a/server/internal/services/stepscan.go +++ b/server/internal/services/stepscan.go @@ -5,12 +5,8 @@ import ( "strings" ) - var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`) - - - func DeriveOutputs(script string) []string { out := []string{} seen := map[string]bool{} @@ -20,7 +16,7 @@ func DeriveOutputs(script string) []string { } for _, m := range keyAssign.FindAllStringSubmatch(line, -1) { key := m[1] - + if key == "WORKFLOW_ENV" || key == "env" { continue } @@ -34,11 +30,5 @@ func DeriveOutputs(script string) []string { return out } -var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) - - -func Slugify(name string) string { - s := strings.ToLower(name) - s = slugStrip.ReplaceAllString(s, "-") - return strings.Trim(s, "-") -} +// Slugify lived here and was mirrored by hand in sitesvc. It now has a single +// definition in shared/provision, which both services import. diff --git a/server/internal/services/sync.go b/server/internal/services/sync.go index 9cc4dd3..0803c83 100644 --- a/server/internal/services/sync.go +++ b/server/internal/services/sync.go @@ -13,17 +13,15 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - - srv, err := getServerByID(serverID) if err != nil { return nil, err } cursor, err := db.Col("assignments").Find(ctx, bson.M{ - "org_id": srv.OrgID, - "server_id": serverID, - "revoked_at": nil, + "instance_id": srv.InstanceID, + "server_id": serverID, + "revoked_at": nil, }) if err != nil { return nil, err @@ -38,7 +36,7 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) { var lines []string for _, a := range assignments { var key models.Key - err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": srv.OrgID}).Decode(&key) + err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": srv.InstanceID}).Decode(&key) if err != nil { continue } diff --git a/server/internal/services/users.go b/server/internal/services/users.go index ceb326b..99b7c67 100644 --- a/server/internal/services/users.go +++ b/server/internal/services/users.go @@ -7,88 +7,59 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/mrhid6/vantage/server/internal/db" "github.com/mrhid6/vantage/server/internal/models" + "github.com/mrhid6/vantage/shared/provision" "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" "golang.org/x/crypto/bcrypt" ) - - var ErrLastOwner = errors.New("this is the organization's last owner promote another member to owner first") - - - func CountUsers() (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return db.Col("users").CountDocuments(ctx, bson.M{}) } -func CountOrgUsers(orgID string) (int64, error) { +func CountInstanceUsers(instanceID string) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - return db.Col("users").CountDocuments(ctx, bson.M{"org_id": orgID}) + return db.Col("users").CountDocuments(ctx, bson.M{"instance_id": instanceID}) } - - -func countOtherOwners(orgID, exceptUserID string) (int64, error) { +func countOtherOwners(instanceID, exceptUserID string) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return db.Col("users").CountDocuments(ctx, bson.M{ - "org_id": orgID, - "role": models.RoleOwner, - "user_id": bson.M{"$ne": exceptUserID}, + "instance_id": instanceID, + "role": models.RoleOwner, + "user_id": bson.M{"$ne": exceptUserID}, }) } -func GetUserInOrg(orgID, userID string) (*models.User, error) { +func GetUserInInstance(instanceID, userID string) (*models.User, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var u models.User - err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "org_id": orgID}).Decode(&u) + err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID}).Decode(&u) if err != nil { return nil, err } return &u, nil } -func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) { - email = strings.ToLower(strings.TrimSpace(email)) - if email == "" { - return nil, fmt.Errorf("email required") - } - if !models.ValidRole(role) { - return nil, fmt.Errorf("invalid role %q", role) - } - u := &models.User{ - UserID: uuid.NewString(), - OrgID: orgID, - Email: email, - Role: role, - AuthSource: authSource, - CreatedAt: time.Now(), - } - if password != "" { - hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) - if err != nil { - return nil, err - } - u.PasswordHash = string(hash) - } +func CreateUser(instanceID, email, password, role, authSource string) (*models.User, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if _, err := db.Col("users").InsertOne(ctx, u); err != nil { - if mongo.IsDuplicateKeyError(err) { - return nil, fmt.Errorf("email already registered") - } - return nil, err + + u, err := provision.CreateUser(ctx, db.Database, instanceID, email, password, role, authSource) + if errors.Is(err, provision.ErrEmailTaken) { + // Preserve the exact error string the API returned before this call + // was delegated to the shared module. + return nil, fmt.Errorf("email already registered") } - return u, nil + return u, err } func GetUserByEmail(email string) (*models.User, error) { @@ -119,10 +90,10 @@ func TouchLastLogin(userID string) error { return err } -func ListUsers(orgID string) ([]models.User, error) { +func ListUsers(instanceID string) ([]models.User, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID}) + cursor, err := db.Col("users").Find(ctx, bson.M{"instance_id": instanceID}) if err != nil { return nil, err } @@ -134,17 +105,17 @@ func ListUsers(orgID string) ([]models.User, error) { return users, nil } -func UpdateUserRole(orgID, userID, role string) error { +func UpdateUserRole(instanceID, userID, role string) error { if !models.ValidRole(role) { return fmt.Errorf("invalid role %q", role) } - target, err := GetUserInOrg(orgID, userID) + target, err := GetUserInInstance(instanceID, userID) if err != nil { return fmt.Errorf("user not found") } - + if target.Role == models.RoleOwner && role != models.RoleOwner { - others, err := countOtherOwners(orgID, userID) + others, err := countOtherOwners(instanceID, userID) if err != nil { return err } @@ -156,18 +127,18 @@ func UpdateUserRole(orgID, userID, role string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err = db.Col("users").UpdateOne(ctx, - bson.M{"user_id": userID, "org_id": orgID}, + bson.M{"user_id": userID, "instance_id": instanceID}, bson.M{"$set": bson.M{"role": role}}) return err } -func DeleteUser(orgID, userID string) error { - target, err := GetUserInOrg(orgID, userID) +func DeleteUser(instanceID, userID string) error { + target, err := GetUserInInstance(instanceID, userID) if err != nil { return fmt.Errorf("user not found") } if target.Role == models.RoleOwner { - others, err := countOtherOwners(orgID, userID) + others, err := countOtherOwners(instanceID, userID) if err != nil { return err } @@ -178,6 +149,6 @@ func DeleteUser(orgID, userID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "org_id": orgID}) + _, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID}) return err } diff --git a/server/internal/services/validate.go b/server/internal/services/validate.go index a3c83e3..404ba89 100644 --- a/server/internal/services/validate.go +++ b/server/internal/services/validate.go @@ -6,7 +6,6 @@ import ( "github.com/mrhid6/vantage/server/internal/models" ) - func ValidateWorkflow(w models.Workflow) error { for i, ref := range w.Steps { hasLib := ref.StepID != "" diff --git a/server/internal/services/workflow_runner.go b/server/internal/services/workflow_runner.go index 0da1cfb..8634e21 100644 --- a/server/internal/services/workflow_runner.go +++ b/server/internal/services/workflow_runner.go @@ -17,8 +17,8 @@ import ( const stepDispatchGrace = 15 * time.Second -func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { - wf, err := GetWorkflow(orgID, workflowID) +func TriggerWorkflow(instanceID, workflowID, actor string) (string, error) { + wf, err := GetWorkflow(instanceID, workflowID) if err != nil { return "", err } @@ -29,24 +29,24 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { return "", fmt.Errorf("workflow has no steps") } - if err := validateTargetServers(orgID, wf.TargetServerIDs); err != nil { + if err := validateTargetServers(instanceID, wf.TargetServerIDs); err != nil { return "", err } ctx, cancel := wfCtx() - running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"}) + running := db.Col("workflow_runs").FindOne(ctx, bson.M{"instance_id": instanceID, "workflow_id": workflowID, "status": "running"}) cancel() if running.Err() == nil { return "", fmt.Errorf("workflow already has a run in progress") } - resolved, err := resolveSteps(orgID, wf) + resolved, err := resolveSteps(instanceID, wf) if err != nil { return "", err } run := models.WorkflowRun{ - OrgID: orgID, + InstanceID: instanceID, RunID: uuid.New().String(), WorkflowID: workflowID, Name: wf.Name, @@ -78,7 +78,7 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { return run.RunID, nil } -func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) { +func resolveSteps(instanceID string, wf *models.Workflow) ([]models.ResolvedStep, error) { ctx, cancel := wfCtx() defer cancel() out := make([]models.ResolvedStep, 0, len(wf.Steps)) @@ -87,7 +87,7 @@ func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, err out = append(out, resolveInlineStep(ref)) continue } - lib, err := getStep(ctx, orgID, ref.StepID) + lib, err := getStep(ctx, instanceID, ref.StepID) if err != nil { return nil, err } @@ -163,7 +163,7 @@ func executeRun(runID string) { done := make(chan int, len(run.ServerRuns)) for i := range run.ServerRuns { go func(idx int) { - runServer(run.OrgID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID) + runServer(run.InstanceID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID) done <- idx }(i) } @@ -185,7 +185,7 @@ func executeRun(runID string) { bson.M{"$set": bson.M{"status": status, "finished_at": now}}) } -func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { +func runServer(instanceID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { now := time.Now() setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now}) @@ -212,7 +212,7 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser maxAttempts = step.MaxRetries + 1 } - secretVals := resolveSecrets(orgID, step.SecretRefs) + secretVals := resolveSecrets(instanceID, step.SecretRefs) for k, v := range secretVals { allSecrets[k] = v } @@ -347,7 +347,7 @@ func expandVars(v string, lookup map[string]string) string { }) } -func resolveSecrets(orgID string, refs []string) map[string]string { +func resolveSecrets(instanceID string, refs []string) map[string]string { out := map[string]string{} for _, ref := range refs { @@ -355,7 +355,7 @@ func resolveSecrets(orgID string, refs []string) map[string]string { if len(parts) != 2 { continue } - if v, err := RevealSecret(orgID, parts[0], parts[1]); err == nil { + if v, err := RevealSecret(instanceID, parts[0], parts[1]); err == nil { out[parts[1]] = v } } @@ -453,21 +453,21 @@ func getRunByID(runID string) (*models.WorkflowRun, error) { return &r, err } -func GetRun(orgID, runID string) (*models.WorkflowRun, error) { +func GetRun(instanceID, runID string) (*models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() var r models.WorkflowRun - err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "org_id": orgID}).Decode(&r) + err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "instance_id": instanceID}).Decode(&r) if err == mongo.ErrNoDocuments { return nil, fmt.Errorf("run not found") } return &r, err } -func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, error) { +func ListRuns(instanceID, workflowID string, limit int64) ([]models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID}, + cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"instance_id": instanceID, "workflow_id": workflowID}, options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit)) if err != nil { return nil, err @@ -480,12 +480,12 @@ func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, erro return runs, nil } -func CancelRun(orgID, runID string) error { +func CancelRun(instanceID, runID string) error { now := time.Now() ctx, cancel := wfCtx() defer cancel() _, err := db.Col("workflow_runs").UpdateOne(ctx, - bson.M{"org_id": orgID, "run_id": runID, "status": "running"}, + bson.M{"instance_id": instanceID, "run_id": runID, "status": "running"}, bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}}) return err } diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index c6d7891..4b6abf4 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -25,13 +25,12 @@ func EnsureWorkflowIndexes() error { }); err != nil { return err } - - + if err := db.Col("workflow_steps").Indexes().DropOne(ctx, "slug_1"); err != nil && !isIndexNotFound(err) { return err } if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "slug", Value: 1}}, + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "slug", Value: 1}}, Options: options.Index().SetUnique(true). SetPartialFilterExpression(bson.M{"source": "default"}), }); err != nil { @@ -48,12 +47,10 @@ func EnsureWorkflowIndexes() error { return err } - - -func ListSteps(orgID string) ([]models.WorkflowStep, error) { +func ListSteps(instanceID string) ([]models.WorkflowStep, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"org_id": orgID}, + cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) if err != nil { return nil, err @@ -66,12 +63,10 @@ func ListSteps(orgID string) ([]models.WorkflowStep, error) { return steps, nil } - - -func StepUsageCounts(orgID string) (map[string]int, error) { +func StepUsageCounts(instanceID string) (map[string]int, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID}) + cur, err := db.Col("workflows").Find(ctx, bson.M{"instance_id": instanceID}) if err != nil { return nil, err } @@ -94,10 +89,10 @@ func StepUsageCounts(orgID string) (map[string]int, error) { return counts, nil } -func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, error) { +func CreateStep(instanceID string, s models.WorkflowStep) (*models.WorkflowStep, error) { ctx, cancel := wfCtx() defer cancel() - s.OrgID = orgID + s.InstanceID = instanceID s.StepID = uuid.New().String() s.CreatedAt = time.Now() s.UpdatedAt = s.CreatedAt @@ -117,10 +112,10 @@ func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, erro return &s, nil } -func UpdateStep(orgID, stepID string, s models.WorkflowStep) error { +func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error { ctx, cancel := wfCtx() defer cancel() - _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}, bson.M{"$set": bson.M{ + _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}, bson.M{"$set": bson.M{ "name": s.Name, "description": s.Description, "interpreter": s.Interpreter, @@ -133,14 +128,14 @@ func UpdateStep(orgID, stepID string, s models.WorkflowStep) error { return err } -func DeleteStep(orgID, stepID string) error { +func DeleteStep(instanceID, stepID string) error { ctx, cancel := wfCtx() defer cancel() - if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil { + if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}); err != nil { return err } - - cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID}) + + cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "instance_id": instanceID}) if err != nil { return err } @@ -170,21 +165,19 @@ func DeleteStep(orgID, stepID string) error { return nil } -func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, error) { +func getStep(ctx context.Context, instanceID, stepID string) (*models.WorkflowStep, error) { var s models.WorkflowStep - err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}).Decode(&s) + err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}).Decode(&s) if err == mongo.ErrNoDocuments { return nil, fmt.Errorf("step %s not found", stepID) } return &s, err } - - -func ListWorkflows(orgID string) ([]models.Workflow, error) { +func ListWorkflows(instanceID string) ([]models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID}, + cur, err := db.Col("workflows").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) if err != nil { return nil, err @@ -197,21 +190,21 @@ func ListWorkflows(orgID string) ([]models.Workflow, error) { return wfs, nil } -func GetWorkflow(orgID, id string) (*models.Workflow, error) { +func GetWorkflow(instanceID, id string) (*models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() var w models.Workflow - err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}).Decode(&w) + err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}).Decode(&w) if err == mongo.ErrNoDocuments { return nil, fmt.Errorf("workflow not found") } return &w, err } -func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) { +func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() - w.OrgID = orgID + w.InstanceID = instanceID w.WorkflowID = uuid.New().String() w.CreatedAt = time.Now() w.UpdatedAt = w.CreatedAt @@ -224,7 +217,7 @@ func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) { if err := ValidateWorkflow(w); err != nil { return nil, err } - if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil { + if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil { return nil, err } normalizeInlineSteps(&w) @@ -234,17 +227,17 @@ func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) { return &w, nil } -func UpdateWorkflow(orgID, id string, w models.Workflow) error { +func UpdateWorkflow(instanceID, id string, w models.Workflow) error { ctx, cancel := wfCtx() defer cancel() if err := ValidateWorkflow(w); err != nil { return err } - if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil { + if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil { return err } normalizeInlineSteps(&w) - _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}, bson.M{"$set": bson.M{ + _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}, bson.M{"$set": bson.M{ "name": w.Name, "target_server_ids": w.TargetServerIDs, "steps": w.Steps, @@ -253,20 +246,15 @@ func UpdateWorkflow(orgID, id string, w models.Workflow) error { return err } - - - -func validateTargetServers(orgID string, serverIDs []string) error { +func validateTargetServers(instanceID string, serverIDs []string) error { for _, sid := range serverIDs { - if _, err := GetServer(orgID, sid); err != nil { + if _, err := GetServer(instanceID, sid); err != nil { return fmt.Errorf("target server %s not found", sid) } } return nil } - - func normalizeInlineSteps(w *models.Workflow) { for i := range w.Steps { in := w.Steps[i].Inline @@ -288,9 +276,9 @@ func normalizeInlineSteps(w *models.Workflow) { } } -func DeleteWorkflow(orgID, id string) error { +func DeleteWorkflow(instanceID, id string) error { ctx, cancel := wfCtx() defer cancel() - _, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}) + _, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}) return err } diff --git a/shared/go.mod b/shared/go.mod new file mode 100644 index 0000000..3f6edc4 --- /dev/null +++ b/shared/go.mod @@ -0,0 +1,19 @@ +module github.com/mrhid6/vantage/shared + +go 1.26.4 + +require ( + github.com/google/uuid v1.6.0 + go.mongodb.org/mongo-driver/v2 v2.8.0 + golang.org/x/crypto v0.54.0 +) + +require ( + github.com/klauspost/compress v1.17.6 // 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 + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/shared/go.sum b/shared/go.sum new file mode 100644 index 0000000..ca1c00b --- /dev/null +++ b/shared/go.sum @@ -0,0 +1,48 @@ +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +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= +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= +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/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/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= diff --git a/shared/indexes/indexes.go b/shared/indexes/indexes.go new file mode 100644 index 0000000..ef11974 --- /dev/null +++ b/shared/indexes/indexes.go @@ -0,0 +1,39 @@ +// Package indexes declares the MongoDB indexes more than one Vantage service +// depends on. +package indexes + +import ( + "context" + "fmt" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug. +// +// These are a security property, not an optimisation. GetUserByEmail does an +// unscoped FindOne, so a duplicate email would let the OIDC cross-org guard +// compare against an arbitrary user. Every caller must treat a failure here as +// fatal. +// +// Creating an index that already exists with the same specification is a no-op, +// so this is safe to call at every boot from every service. +func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error { + if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("users.email index: %w", err) + } + + if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "slug", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return fmt.Errorf("instances.slug index: %w", err) + } + + return nil +} diff --git a/shared/models/instance.go b/shared/models/instance.go new file mode 100644 index 0000000..1bb140b --- /dev/null +++ b/shared/models/instance.go @@ -0,0 +1,23 @@ +// Package models holds the MongoDB documents written by more than one Vantage +// service. Documents only the control plane touches stay in +// server/internal/models. +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Instance is one deployment of Vantage: its own subdomain, users, servers, +// keys, workflows, monitors and secrets. It is the unit a licence attaches to. +// +// A paying customer may hold several. That grouping is called an Account and +// lives only in the admin control plane — this service never sees it. +type Instance struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} diff --git a/shared/models/settings.go b/shared/models/settings.go new file mode 100644 index 0000000..449ac91 --- /dev/null +++ b/shared/models/settings.go @@ -0,0 +1,40 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AlertSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + WebhookURL string `bson:"webhook_url" json:"webhook_url"` + OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"` +} + +type EmailSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + SMTPHost string `bson:"smtp_host" json:"smtp_host"` + SMTPPort int `bson:"smtp_port" json:"smtp_port"` + Username string `bson:"username" json:"username"` + Password string `bson:"password" json:"password"` + FromAddr string `bson:"from_addr" json:"from_addr"` + ToAddrs []string `bson:"to_addrs" json:"to_addrs"` + UseTLS bool `bson:"use_tls" json:"use_tls"` +} + +type SecretsSettings struct { + ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"` + ReadTokenSet bool `bson:"-" json:"read_token_set"` + RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"` +} + +type Settings struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Alerts AlertSettings `bson:"alerts" json:"alerts"` + Email EmailSettings `bson:"email" json:"email"` + Secrets SecretsSettings `bson:"secrets" json:"secrets"` + + WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` +} diff --git a/shared/models/user.go b/shared/models/user.go new file mode 100644 index 0000000..1e62040 --- /dev/null +++ b/shared/models/user.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +func ValidRole(role string) bool { + switch role { + case RoleOwner, RoleAdmin, RoleMember: + return true + } + return false +} + +type User struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + UserID string `bson:"user_id" json:"user_id"` + InstanceID string `bson:"instance_id" json:"instance_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash,omitempty" json:"-"` + Role string `bson:"role" json:"role"` + AuthSource string `bson:"auth_source" json:"auth_source"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` +} diff --git a/shared/provision/instance.go b/shared/provision/instance.go new file mode 100644 index 0000000..3dc6bde --- /dev/null +++ b/shared/provision/instance.go @@ -0,0 +1,74 @@ +package provision + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ErrNameRejected wraps every reason a name cannot become an instance. +var ErrNameRejected = errors.New("organisation name rejected") + +const maxSlugAttempts = 50 + +// CreateInstance inserts an instance under the first free slug derived from name. +// +// The count-then-insert loop is racy on its own. It is safe only because +// instances.slug carries a unique index: a lost race surfaces as a duplicate-key +// error, which we treat as "that slug is taken" and retry. Do not remove the +// duplicate-key branch, and do not remove the index. +func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { + base, err := BaseSlug(name) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) + } + + for attempt := 1; attempt <= maxSlugAttempts; attempt++ { + slug := NextSlug(base, attempt) + + n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug}) + if err != nil { + return nil, err + } + if n > 0 { + continue + } + + inst := models.Instance{ + InstanceID: uuid.NewString(), + Name: name, + Slug: slug, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil { + if mongo.IsDuplicateKeyError(err) { + continue // lost the race; try the next slug + } + return nil, err + } + return &inst, nil + } + return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) +} + +// RollbackInstance deletes an instance that has no users. +// +// It refuses an instance that has users. Rollback exists to clean up a +// half-finished signup, and an instance with users is not half-finished. +func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error { + n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if n > 0 { + return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n) + } + _, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID}) + return err +} diff --git a/sitesvc/internal/provision/provision.go b/shared/provision/slug.go similarity index 54% rename from sitesvc/internal/provision/provision.go rename to shared/provision/slug.go index f2d4bdf..9656448 100644 --- a/sitesvc/internal/provision/provision.go +++ b/shared/provision/slug.go @@ -1,3 +1,10 @@ +// Package provision holds the tenant creation rules shared by the control +// plane and sitesvc. +// +// These rules used to be duplicated: the control plane owned one copy and +// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried +// on a lost slug race while the control plane returned an error. This package +// is the single definition; neither service may reimplement any of it. package provision import ( @@ -6,39 +13,28 @@ import ( "strings" ) -/* -Slug rules mirrored from the control plane (server/internal/services: Slugify in -stepscan.go, reservedSlugs and CreateOrg in orgs.go). - -They live here rather than being imported because sitesvc is a separate module -with no dependency on the server. That is a deliberate trade: sitesvc stays -small and independent, at the cost of this one duplicated rule set. - -Keep the two in step. If the control plane's slug handling, reserved names or -bcrypt cost change, change them here in the same commit nothing enforces the -match automatically, and a divergence would create tenants under rules the app -does not agree with. -*/ - const ( MinSlugLength = 3 MaxSlugLength = 40 - BcryptCost = 12 ) var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) +// ReservedSlugs are subdomain labels the platform needs for itself. var ReservedSlugs = map[string]bool{ "www": true, "api": true, "app": true, "admin": true, "auth": true, "install": true, "static": true, "_next": true, "default": true, } +// Slugify lowercases a name and collapses every run of non-alphanumeric +// characters into a single hyphen, trimming hyphens from both ends. func Slugify(name string) string { s := strings.ToLower(name) s = slugStrip.ReplaceAllString(s, "-") return strings.Trim(s, "-") } +// BaseSlug turns a name into a validated slug stem, or explains why it cannot. func BaseSlug(name string) (string, error) { base := Slugify(name) if len(base) < MinSlugLength { @@ -53,6 +49,8 @@ func BaseSlug(name string) (string, error) { return base, nil } +// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the +// base itself; later attempts append a counter. func NextSlug(base string, attempt int) string { if attempt < 2 { return base diff --git a/shared/provision/user.go b/shared/provision/user.go new file mode 100644 index 0000000..ab27cc4 --- /dev/null +++ b/shared/provision/user.go @@ -0,0 +1,65 @@ +package provision + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +// BcryptCost is the work factor for every password hash Vantage writes. +// Changing it changes nothing about existing hashes, which carry their own cost. +const BcryptCost = 12 + +// ErrEmailTaken is returned when the unique index on users.email rejects an insert. +var ErrEmailTaken = errors.New("email already registered") + +// CreateUser hashes password and inserts the user. An empty password leaves the +// hash empty, which is how OIDC users are stored. +func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) { + var hash string + if password != "" { + b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return nil, err + } + hash = string(b) + } + return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource) +} + +// CreateUserWithHash inserts a user whose password was already hashed +// elsewhere. sitesvc hashes at signup and only holds the hash by the time the +// verification link is opened. +func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, role, authSource string) (*models.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil, fmt.Errorf("email required") + } + if !models.ValidRole(role) { + return nil, fmt.Errorf("invalid role %q", role) + } + + u := &models.User{ + UserID: uuid.NewString(), + InstanceID: instanceID, + Email: email, + PasswordHash: passwordHash, + Role: role, + AuthSource: authSource, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Collection("users").InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, ErrEmailTaken + } + return nil, err + } + return u, nil +} diff --git a/site/app/page.tsx b/site/app/page.tsx index 36f806a..7cc3110 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -14,7 +14,7 @@ export default function OverviewPage() {

- Create your organisation + Create your instance What it does @@ -133,8 +133,8 @@ export default function OverviewPage() {
FIRST -

Create an organisation

-

You become the owner. Everything inside is invisible to every other organisation.

+

Create an instance

+

You become the owner. Everything inside is invisible to every other instance.

THEN @@ -168,7 +168,7 @@ export default function OverviewPage() {

Three servers, free, no card.

-

Create an organisation, install one agent, and watch a key land on a real box.

+

Create an instance, install one agent, and watch a key land on a real box.

Get started diff --git a/site/app/platform/page.tsx b/site/app/platform/page.tsx index 763e1d4..4c1cfcc 100644 --- a/site/app/platform/page.tsx +++ b/site/app/platform/page.tsx @@ -82,13 +82,13 @@ export default function PlatformPage() {
Tenancy and identity -

Organisations are the boundary.

+

Instances are the boundary.

Isolation

Scoped at the query

- Every server, key, workflow, monitor and secret belongs to an organisation, and every lookup is filtered by it. Uniqueness constraints are enforced by the database, not by + Every server, key, workflow, monitor and secret belongs to an instance, and every lookup is filtered by it. Uniqueness constraints are enforced by the database, not by application logic.

@@ -99,8 +99,8 @@ export default function PlatformPage() {
Identity -

Local or OIDC, per organisation

-

Sign in with email and password, or connect your own provider. Each organisation configures its own issuer and client.

+

Local or OIDC, per instance

+

Sign in with email and password, or connect your own provider. Each instance configures its own issuer and client.

Sessions diff --git a/site/app/pricing/page.tsx b/site/app/pricing/page.tsx index 720a807..e39733d 100644 --- a/site/app/pricing/page.tsx +++ b/site/app/pricing/page.tsx @@ -45,7 +45,7 @@ export default function PricingPage() {
  • Up to 3 servers
  • Keys, workflows and monitors
  • -
  • One member, one organisation
  • +
  • One member, one instance
  • Community support
diff --git a/site/app/start/page.tsx b/site/app/start/page.tsx index 8f3e2e0..269ca44 100644 --- a/site/app/start/page.tsx +++ b/site/app/start/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; -import { OrgForm } from "@/components/OrgForm"; +import { InstanceForm } from "@/components/InstanceForm"; export const metadata: Metadata = { title: "Vantage Cloud", - description: "An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.", + description: "An instance owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.", }; export default function StartPage() { @@ -12,14 +12,14 @@ export default function StartPage() {
Vantage Cloud -

Set up your organisation.

+

Set up your instance.

- An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other organisation. Confirm your email and it is created with you + An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Confirm your email and it is created with you as its owner.

- +
@@ -30,7 +30,7 @@ export default function StartPage() { FIRST

Confirm your email

-

We send a link that works once. Your organisation is created when you open it, not before.

+

We send a link that works once. Your instance is created when you open it, not before.

diff --git a/site/components/OrgForm.tsx b/site/components/InstanceForm.tsx similarity index 82% rename from site/components/OrgForm.tsx rename to site/components/InstanceForm.tsx index b03b47b..c736dc7 100644 --- a/site/components/OrgForm.tsx +++ b/site/components/InstanceForm.tsx @@ -14,7 +14,7 @@ function slugify(value: string) { .replace(/^-|-$/g, ""); } -export function OrgForm() { +export function InstanceForm() { const [slug, setSlug] = useState(""); const [result, setResult] = useState({ state: "idle" }); const sending = result.state === "sending"; @@ -25,7 +25,7 @@ export function OrgForm() { setResult({ state: "sending" }); setResult( await submitSignup({ - org_name: String(data.get("org_name") ?? ""), + instance_name: String(data.get("instance_name") ?? ""), email: String(data.get("email") ?? ""), password: String(data.get("password") ?? ""), website: String(data.get("website") ?? ""), @@ -38,7 +38,7 @@ export function OrgForm() {

Check your email.

- We sent a confirmation link. Open it and {slug || "your organisation"} is created with you as its owner. The link works once and expires in 24 hours. + We sent a confirmation link. Open it and {slug || "your instance"} is created with you as its owner. The link works once and expires in 24 hours.

Nothing exists until you confirm if the email does not arrive, start again or contact support@hostxtra.co.uk. @@ -54,14 +54,14 @@ export function OrgForm() {

- - setSlug(slugify(e.target.value))} aria-describedby="o-org-err" /> + + setSlug(slugify(e.target.value))} aria-describedby="o-instance-err" /> - {slug || "your-org"}.vantage.hostxtra.co.uk + {slug || "your-instance"}.vantage.hostxtra.co.uk - {fieldError("org_name") && ( - - {fieldError("org_name")} + {fieldError("instance_name") && ( + + {fieldError("instance_name")} )}
diff --git a/site/lib/submit.ts b/site/lib/submit.ts index 328134e..bfc5537 100644 --- a/site/lib/submit.ts +++ b/site/lib/submit.ts @@ -56,7 +56,7 @@ export async function submitContact(fields: { name: string; email: string; serve return post(`${SITE_API}/api/contact`, fields); } -export async function submitSignup(fields: { org_name: string; email: string; password: string; website: string }): Promise { +export async function submitSignup(fields: { instance_name: string; email: string; password: string; website: string }): Promise { if (!SITE_API) { return { state: "error", diff --git a/site/next-env.d.ts b/site/next-env.d.ts index 9fd409f..9edff1c 100644 --- a/site/next-env.d.ts +++ b/site/next-env.d.ts @@ -1,6 +1,6 @@ -/ -/ -import "./.next/dev/types/routes.d.ts"; - - +/// +/// +import "./.next/types/routes.d.ts"; +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/sitesvc/Dockerfile b/sitesvc/Dockerfile index 97c124f..f39689b 100644 --- a/sitesvc/Dockerfile +++ b/sitesvc/Dockerfile @@ -1,13 +1,16 @@ +# Context is the repository root; sitesvc depends on the shared module. FROM golang:1.26-alpine AS builder -WORKDIR /app +WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download +COPY shared/go.mod shared/go.sum ./shared/ +COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/ +RUN cd sitesvc && go mod download -COPY . . +COPY shared/ ./shared/ +COPY sitesvc/ ./sitesvc/ -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd +RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd FROM alpine:3.20 AS runner diff --git a/sitesvc/cmd/main.go b/sitesvc/cmd/main.go index 309db86..1dbd2d5 100644 --- a/sitesvc/cmd/main.go +++ b/sitesvc/cmd/main.go @@ -28,6 +28,13 @@ func main() { } log.Printf("connected to MongoDB (database %q)", store.DatabaseName()) + guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second) + guardErr := store.RequireMigratedDatabase(guardCtx) + guardCancel() + if guardErr != nil { + log.Fatalf("database check failed: %v", guardErr) + } + if err := store.EnsureIndexes(); err != nil { log.Fatalf("failed to ensure indexes: %v", err) } diff --git a/sitesvc/go.mod b/sitesvc/go.mod index b98190d..f2bb26c 100644 --- a/sitesvc/go.mod +++ b/sitesvc/go.mod @@ -1,17 +1,17 @@ module github.com/mrhid6/vantage/sitesvc -go 1.26 +go 1.26.4 require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - go.mongodb.org/mongo-driver/v2 v2.2.2 + go.mongodb.org/mongo-driver/v2 v2.8.0 golang.org/x/crypto v0.54.0 ) require ( - github.com/golang/snappy v1.0.0 // indirect github.com/klauspost/compress v1.17.6 // indirect + github.com/mrhid6/vantage/shared v0.0.0 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 @@ -19,3 +19,5 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect ) + +replace github.com/mrhid6/vantage/shared => ../shared diff --git a/sitesvc/go.sum b/sitesvc/go.sum index 3f296f9..4ed559c 100644 --- a/sitesvc/go.sum +++ b/sitesvc/go.sum @@ -1,7 +1,5 @@ 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/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= -github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -19,8 +17,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi 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= -go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c= -go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps= +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= 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= diff --git a/sitesvc/internal/api/ratelimit.go b/sitesvc/internal/api/ratelimit.go index fc3539c..5bd2258 100644 --- a/sitesvc/internal/api/ratelimit.go +++ b/sitesvc/internal/api/ratelimit.go @@ -5,10 +5,6 @@ import ( "time" ) - - - - type limiter struct { mu sync.Mutex hits map[string]*window @@ -51,8 +47,6 @@ func (l *limiter) allow(key string) bool { return true } - - func (l *limiter) gc(now time.Time) { if now.Sub(l.lastGC) < l.window { return diff --git a/sitesvc/internal/api/signup.go b/sitesvc/internal/api/signup.go index 670848a..930eac8 100644 --- a/sitesvc/internal/api/signup.go +++ b/sitesvc/internal/api/signup.go @@ -22,10 +22,10 @@ const ( ) type signupBody struct { - OrgName string `json:"org_name"` - Email string `json:"email"` - Password string `json:"password"` - Website string `json:"website"` + InstanceName string `json:"instance_name"` + Email string `json:"email"` + Password string `json:"password"` + Website string `json:"website"` } func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { @@ -41,7 +41,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { var problems []fieldError - orgName, nameErr := text("org_name", body.OrgName, true, maxShort) + instanceName, nameErr := text("instance_name", body.InstanceName, true, maxShort) if nameErr != nil { problems = append(problems, *nameErr) } @@ -82,7 +82,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - token, err := store.CreatePending(ctx, orgName, addr, body.Password) + token, err := store.CreatePending(ctx, instanceName, addr, body.Password) switch { case errors.Is(err, store.ErrEmailTaken): writeJSON(w, http.StatusConflict, map[string]any{ @@ -95,7 +95,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { return case errors.Is(err, store.ErrNameRejected): writeFieldErrors(w, []fieldError{{ - Field: "org_name", + Field: "instance_name", Message: strings.TrimPrefix(err.Error(), store.ErrNameRejected.Error()+": "), }}) return @@ -108,7 +108,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { } link := s.verifyURL(token) - if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil { + if err := s.mail.SendVerification(addr, instanceName, link, store.PendingTTL); err != nil { log.Printf("signup: send verification to %s: %v", addr, err) writeJSON(w, http.StatusBadGateway, map[string]string{ @@ -136,7 +136,7 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() - org, err := store.Verify(ctx, token) + inst, err := store.Verify(ctx, token) switch { case errors.Is(err, store.ErrBadToken): s.verifyPage(w, http.StatusGone, "Link expired", @@ -157,23 +157,23 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) { return } - log.Printf("verify: provisioned org %s (%s)", org.Slug, org.OrgID) + log.Printf("verify: provisioned instance %s (%s)", inst.Slug, inst.InstanceID) - if login := s.loginURL(org.Slug); login != "" { + if login := s.loginURL(inst.Slug); login != "" { http.Redirect(w, r, login, http.StatusSeeOther) return } s.verifyPage(w, http.StatusOK, "Organisation ready", - fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name)) + fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", inst.Name)) } -// loginURL is the org-specific sign-in URL a verified owner is sent to. Each -// org lives on its own subdomain (.vantage.hostxtra.co.uk), so the slug +// loginURL is the instance-specific sign-in URL a verified owner is sent to. Each +// instance lives on its own subdomain (.vantage.hostxtra.co.uk), so the slug // must be substituted per signup rather than pointing at one shared address. // // APP_LOGIN_URL is a template. A "{slug}" placeholder is replaced with the -// org's slug; a value without one is treated as a literal (a single shared +// instance's slug; a value without one is treated as a literal (a single shared // login page) so a plain URL still works. An empty value falls back to the // confirmation page. func (s *Server) loginURL(slug string) string { diff --git a/sitesvc/internal/api/validate.go b/sitesvc/internal/api/validate.go index ebfa6e3..ecd459d 100644 --- a/sitesvc/internal/api/validate.go +++ b/sitesvc/internal/api/validate.go @@ -7,8 +7,6 @@ import ( "unicode/utf8" ) - - var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`) const ( @@ -23,9 +21,6 @@ type fieldError struct { func (e fieldError) Error() string { return e.Field + ": " + e.Message } - - - func text(name, value string, required bool, max int) (string, *fieldError) { v := strings.TrimSpace(value) if v == "" { @@ -55,9 +50,6 @@ func email(name, value string) (string, *fieldError) { return v, nil } - - - func oneOf(name, value string, allowed []string) (string, *fieldError) { v := strings.TrimSpace(value) for _, a := range allowed { diff --git a/sitesvc/internal/mail/mail.go b/sitesvc/internal/mail/mail.go index 9ca27d3..87ce0a1 100644 --- a/sitesvc/internal/mail/mail.go +++ b/sitesvc/internal/mail/mail.go @@ -150,7 +150,7 @@ func sanitizeHeader(v string) string { return strings.NewReplacer("\r", " ", "\n", " ").Replace(v) } -func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error { +func (c Config) SendVerification(to, instanceName, link string, ttl time.Duration) error { body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage. Open this link: @@ -161,7 +161,7 @@ The link works once and expires in %d hours. Until you use it, no account exists nothing has been created and the address is not registered. If you did not request this, ignore this email and nothing will happen. -`, orgName, link, int(ttl.Hours())) +`, instanceName, link, int(ttl.Hours())) return c.sendTo(to, "Confirm your Vantage organisation", body, "") } diff --git a/sitesvc/internal/models/models.go b/sitesvc/internal/models/models.go index 3a66bbc..57341be 100644 --- a/sitesvc/internal/models/models.go +++ b/sitesvc/internal/models/models.go @@ -6,46 +6,16 @@ import ( "go.mongodb.org/mongo-driver/v2/bson" ) -/* -Org and User mirror server/internal/models field for field, because sitesvc -writes into the same collections the control plane reads. - -These two structs and the rules in internal/provision are the only places -sitesvc duplicates control-plane logic. If the control plane's shape changes, -these must change with it. -*/ - -type Org struct { - ID bson.ObjectID `bson:"_id,omitempty"` - OrgID string `bson:"org_id"` - Name string `bson:"name"` - Slug string `bson:"slug"` - CreatedAt time.Time `bson:"created_at"` -} - -type User struct { - ID bson.ObjectID `bson:"_id,omitempty"` - UserID string `bson:"user_id"` - OrgID string `bson:"org_id"` - Email string `bson:"email"` - PasswordHash string `bson:"password_hash,omitempty"` - Role string `bson:"role"` - AuthSource string `bson:"auth_source"` - CreatedAt time.Time `bson:"created_at"` - LastLogin *time.Time `bson:"last_login,omitempty"` -} - - - - - - - - +// PendingSignup lives here rather than in the shared module because only +// sitesvc writes site_pending_signups. The control plane does not know the +// collection exists. +// +// Org and User used to be mirrored here by hand. They now come from +// github.com/mrhid6/vantage/shared/models, which is the only copy. type PendingSignup struct { ID bson.ObjectID `bson:"_id,omitempty"` PendingID string `bson:"pending_id"` - OrgName string `bson:"org_name"` + InstanceName string `bson:"instance_name"` Email string `bson:"email"` PasswordHash string `bson:"password_hash"` TokenHash string `bson:"token_hash"` diff --git a/sitesvc/internal/store/store.go b/sitesvc/internal/store/store.go index 17e6f79..e3b90b8 100644 --- a/sitesvc/internal/store/store.go +++ b/sitesvc/internal/store/store.go @@ -12,8 +12,10 @@ import ( "time" "github.com/google/uuid" + "github.com/mrhid6/vantage/shared/indexes" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "github.com/mrhid6/vantage/shared/provision" "github.com/mrhid6/vantage/sitesvc/internal/models" - "github.com/mrhid6/vantage/sitesvc/internal/provision" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -21,26 +23,18 @@ import ( "golang.org/x/crypto/bcrypt" ) - const PendingTTL = 24 * time.Hour +// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so +// errors.Is keeps working for existing callers in internal/api. var ( - ErrEmailTaken = errors.New("email already registered") + ErrEmailTaken = provision.ErrEmailTaken ErrBadToken = errors.New("verification link is invalid or has expired") - ErrNameRejected = errors.New("organisation name rejected") + ErrNameRejected = provision.ErrNameRejected ) var database *mongo.Database - - - - - - - - - func Connect(uri string) error { cs, err := connstring.ParseAndValidate(uri) if err != nil { @@ -64,7 +58,6 @@ func Connect(uri string) error { return nil } - func DatabaseName() string { if database == nil { return "" @@ -78,18 +71,11 @@ func EnsureIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if _, err := col("users").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "email", Value: 1}}, - Options: options.Index().SetUnique(true), - }); err != nil { - return fmt.Errorf("users.email index: %w", err) - } - - if _, err := col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "slug", Value: 1}}, - Options: options.Index().SetUnique(true), - }); err != nil { - return fmt.Errorf("orgs.slug index: %w", err) + // users.email and instances.slug are declared in the shared module so both + // services agree. Re-declaring at boot means sitesvc does not depend on the + // control plane having started first. + if err := indexes.EnsureCoreIndexes(ctx, database); err != nil { + return err } _, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{ @@ -98,8 +84,7 @@ func EnsureIndexes() error { Options: options.Index().SetUnique(true), }, {Keys: bson.D{{Key: "email", Value: 1}}}, - - + { Keys: bson.D{{Key: "expires_at", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0), @@ -111,13 +96,46 @@ func EnsureIndexes() error { return nil } +// RequireMigratedDatabase refuses to start against a control-plane database +// that has not run migration 0004. +// +// Provisioning into `orgs` while the control plane reads `instances` would +// create tenants nobody can see — the exact skew failure the shared module was +// built to prevent. Failing to start is strictly better. +func RequireMigratedDatabase(ctx context.Context) error { + names, err := database.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + var hasInstances, hasOrgs bool + for _, n := range names { + switch n { + case "instances": + hasInstances = true + case "orgs": + hasOrgs = true + } + } + + // A brand-new database has neither. That is fine — whichever service starts + // first creates `instances`. + if !hasInstances && !hasOrgs { + return nil + } + if !hasInstances { + return errors.New("instances collection not found; deploy the control plane first") + } + return nil +} + func EmailTaken(ctx context.Context, email string) (bool, error) { n, err := col("users").CountDocuments(ctx, bson.M{"email": email}) return n > 0, err } -func CreatePending(ctx context.Context, orgName, email, password string) (string, error) { - if _, err := provision.BaseSlug(orgName); err != nil { +func CreatePending(ctx context.Context, instanceName, email, password string) (string, error) { + if _, err := provision.BaseSlug(instanceName); err != nil { return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) } @@ -139,8 +157,6 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string return "", err } - - if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil { return "", err } @@ -148,7 +164,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string now := time.Now().UTC() pending := models.PendingSignup{ PendingID: uuid.NewString(), - OrgName: strings.TrimSpace(orgName), + InstanceName: strings.TrimSpace(instanceName), Email: email, PasswordHash: string(hash), TokenHash: hashToken(raw), @@ -161,12 +177,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string return raw, nil } - - - - - -func Verify(ctx context.Context, rawToken string) (*models.Org, error) { +func Verify(ctx context.Context, rawToken string) (*sharedmodels.Instance, error) { var pending models.PendingSignup err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{ "token_hash": hashToken(rawToken), @@ -179,87 +190,24 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) { return nil, err } - org, err := createOrg(ctx, pending.OrgName) + inst, err := provision.CreateInstance(ctx, database, pending.InstanceName) if err != nil { return nil, err } - user := models.User{ - UserID: uuid.NewString(), - OrgID: org.OrgID, - Email: pending.Email, - PasswordHash: pending.PasswordHash, - Role: "owner", - AuthSource: "local", - CreatedAt: time.Now().UTC(), - } - if _, err := col("users").InsertOne(ctx, user); err != nil { - - - - - if rbErr := rollbackOrg(ctx, org.OrgID); rbErr != nil { - log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr) - } - if mongo.IsDuplicateKeyError(err) { - return nil, ErrEmailTaken + // The password was hashed when the signup was recorded; only the hash + // survives to this point. + _, err = provision.CreateUserWithHash(ctx, database, inst.InstanceID, pending.Email, + pending.PasswordHash, sharedmodels.RoleOwner, "local") + if err != nil { + // Leaving an instance behind would permanently occupy a slug nobody owns. + if rbErr := provision.RollbackInstance(ctx, database, inst.InstanceID); rbErr != nil { + log.Printf("verify: failed to roll back instance %s: %v", inst.InstanceID, rbErr) } return nil, err } - return org, nil -} - - - -func createOrg(ctx context.Context, name string) (*models.Org, error) { - base, err := provision.BaseSlug(name) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) - } - - for attempt := 1; attempt <= 50; attempt++ { - slug := provision.NextSlug(base, attempt) - - n, err := col("orgs").CountDocuments(ctx, bson.M{"slug": slug}) - if err != nil { - return nil, err - } - if n > 0 { - continue - } - - org := models.Org{ - OrgID: uuid.NewString(), - Name: name, - Slug: slug, - CreatedAt: time.Now().UTC(), - } - if _, err := col("orgs").InsertOne(ctx, org); err != nil { - - - if mongo.IsDuplicateKeyError(err) { - continue - } - return nil, err - } - return &org, nil - } - return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name) -} - - - -func rollbackOrg(ctx context.Context, orgID string) error { - n, err := col("users").CountDocuments(ctx, bson.M{"org_id": orgID}) - if err != nil { - return err - } - if n > 0 { - return fmt.Errorf("refusing to roll back org %s: it has %d user(s)", orgID, n) - } - _, err = col("orgs").DeleteOne(ctx, bson.M{"org_id": orgID}) - return err + return inst, nil } func randomToken() (string, error) { diff --git a/web/app/(app)/settings/org/page.tsx b/web/app/(app)/settings/instance/page.tsx similarity index 90% rename from web/app/(app)/settings/org/page.tsx rename to web/app/(app)/settings/instance/page.tsx index 4e05928..53adfd3 100644 --- a/web/app/(app)/settings/org/page.tsx +++ b/web/app/(app)/settings/instance/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, auth as authApi, type OrgUser, type Role } from "@/lib/api"; +import { api, auth as authApi, type InstanceUser, type Role } from "@/lib/api"; import { useAuth } from "@/components/AuthProvider"; import { Badge, Button, Card, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui"; @@ -35,12 +35,12 @@ function MembersCard() { const [password, setPassword] = useState(""); const [role, setRole] = useState("member"); - const { data: users, isLoading, error } = useQuery({ queryKey: ["org-users"], queryFn: api.listOrgUsers }); + const { data: users, isLoading, error } = useQuery({ queryKey: ["instance-users"], queryFn: api.listInstanceUsers }); - const invalidate = () => queryClient.invalidateQueries({ queryKey: ["org-users"] }); + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["instance-users"] }); const { mutate: createUser, isPending: creating, error: createError } = useMutation({ - mutationFn: () => api.createOrgUser({ email, password, role }), + mutationFn: () => api.createInstanceUser({ email, password, role }), onSuccess: () => { invalidate(); setAddOpen(false); @@ -51,7 +51,7 @@ function MembersCard() { }); const { mutate: changeRole, error: roleError } = useMutation({ - mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next), + mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateInstanceUserRole(userId, next), onSuccess: invalidate, @@ -59,7 +59,7 @@ function MembersCard() { }); const { mutate: removeUser, error: removeError } = useMutation({ - mutationFn: (userId: string) => api.deleteOrgUser(userId), + mutationFn: (userId: string) => api.deleteInstanceUser(userId), onSuccess: invalidate, }); @@ -76,7 +76,7 @@ function MembersCard() {

Members

- People with access to this organization. Owners and admins can manage settings. + People with access to this instance. Owners and admins can manage settings.

-

SSO must be enabled for this organization by an administrator.

+

SSO must be enabled for this instance by an administrator.

diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx index 9503cfe..76b3a6f 100644 --- a/web/app/setup/page.tsx +++ b/web/app/setup/page.tsx @@ -8,17 +8,17 @@ import { Button, Card } from "@/components/ui"; const MIN_PASSWORD_LENGTH = 8; /** - * Org hosts are `.vantage.` and the apex is `vantage.` (see - * auth.hostSlug on the server). Build the new org's URL by prepending or + * Instance hosts are `.vantage.` and the apex is `vantage.` (see + * auth.hostSlug on the server). Build the new instance's URL by prepending or * replacing the leftmost label. Hosts that don't match that shape (localhost, - * bare IPs) have no per-org subdomain, so stay put. + * bare IPs) have no per-instance subdomain, so stay put. * * Setup runs on the apex, and the session cookie it sets is scoped to that - * exact host by design org hosts must not share cookies. So the new owner is - * sent to the org host's *login* page to sign in there, which is what puts a - * session cookie on the host their org actually lives on. + * exact host by design instance hosts must not share cookies. So the new owner is + * sent to the instance host's *login* page to sign in there, which is what puts a + * session cookie on the host their instance actually lives on. */ -function orgLoginUrlForSlug(slug: string): string { +function instanceLoginUrlForSlug(slug: string): string { if (typeof window === "undefined") return "/login"; const { protocol, host } = window.location; const [hostname, port] = host.split(":"); @@ -34,7 +34,7 @@ function orgLoginUrlForSlug(slug: string): string { } export default function SetupPage() { - const [orgName, setOrgName] = useState(""); + const [instanceName, setInstanceName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); @@ -54,9 +54,9 @@ export default function SetupPage() { isPending, error, } = useMutation({ - mutationFn: () => auth.bootstrap({ org_name: orgName, email, password }), + mutationFn: () => auth.bootstrap({ instance_name: instanceName, email, password }), onSuccess: (res) => { - setCreated({ slug: res.slug, loginUrl: orgLoginUrlForSlug(res.slug) }); + setCreated({ slug: res.slug, loginUrl: instanceLoginUrlForSlug(res.slug) }); }, }); @@ -83,13 +83,13 @@ export default function SetupPage() {
-

Organization created

+

Instance created

Your owner account is ready. One more step to finish signing in.

- {created.slug} has its own address, and sign-in is kept separate per organization. Continue to your organization's sign-in page and log in with the email and password + {created.slug} has its own address, and sign-in is kept separate per instance. Continue to your instance's sign-in page and log in with the email and password you just chose.

{created.loginUrl} @@ -109,17 +109,17 @@ export default function SetupPage() {

Welcome to Vantage

-

Create your organization and its owner account to get started.

+

Create your instance and its owner account to get started.

-
diff --git a/web/components/AuthProvider.tsx b/web/components/AuthProvider.tsx index 284f9d1..d978bb5 100644 --- a/web/components/AuthProvider.tsx +++ b/web/components/AuthProvider.tsx @@ -1,18 +1,18 @@ "use client"; import { createContext, useContext, useEffect, useState, ReactNode } from "react"; -import { auth, type Org, type Role, type SessionUser } from "@/lib/api"; +import { auth, type Instance, type Role, type SessionUser } from "@/lib/api"; -export type { Org, Role, SessionUser }; +export type { Instance, Role, SessionUser }; interface AuthContextType { user: SessionUser | null; - org: Org | null; - /** True for owner and admin the roles the /api/settings and /api/org routes require. */ + instance: Instance | null; + /** True for owner and admin the roles the /api/settings and /api/instance routes require. */ isAdmin: boolean; } -const AuthContext = createContext({ user: null, org: null, isAdmin: false }); +const AuthContext = createContext({ user: null, instance: null, isAdmin: false }); export function useAuth() { return useContext(AuthContext); @@ -24,7 +24,7 @@ export function useAuth() { */ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); - const [org, setOrg] = useState(null); + const [instance, setOrg] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -42,7 +42,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const me = await auth.me(); if (cancelled) return; setUser(me.user); - setOrg(me.org); + setOrg(me.instance); setLoading(false); } catch (err) { if (cancelled) return; @@ -91,5 +91,5 @@ export function AuthProvider({ children }: { children: ReactNode }) { const isAdmin = user.role === "owner" || user.role === "admin"; - return {children}; + return {children}; } diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 3e43ab1..c47fdca 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -108,7 +108,7 @@ function StepsIcon() { ); } -function OrgIcon() { +function InstanceIcon() { return ( }, { href: "/steps", label: "Steps", icon: }, { href: "/audit", label: "Audit Log", icon: }, - { href: "/settings/org", label: "Organization", icon: , adminOnly: true }, + { href: "/settings/instance", label: "Instance", icon: , adminOnly: true }, { href: "/settings", label: "Settings", icon: , adminOnly: true }, ]; export function Sidebar() { const pathname = usePathname(); - const { user, org, isAdmin } = useAuth(); + const { user, instance, isAdmin } = useAuth(); const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin); @@ -157,7 +157,7 @@ export function Sidebar() {
Vantage - {org && {org.name}} + {instance && {instance.name}}
diff --git a/web/components/ui/Button.tsx b/web/components/ui/Button.tsx index 8efbb0b..211f546 100644 --- a/web/components/ui/Button.tsx +++ b/web/components/ui/Button.tsx @@ -47,7 +47,7 @@ export const Button = forwardRef( {loading && ( diff --git a/web/lib/api.ts b/web/lib/api.ts index a2df0e5..0fe245a 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -301,14 +301,14 @@ export type Role = "owner" | "admin" | "member"; /** The session as returned by GET /auth/me mirrors auth.Session on the server. */ export interface SessionUser { user_id: string; - org_id: string; + instance_id: string; role: Role; email: string; name: string; } -export interface Org { - org_id: string; +export interface Instance { + instance_id: string; name: string; slug: string; created_at: string; @@ -316,22 +316,22 @@ export interface Org { export interface MeResponse { user: SessionUser; - org: Org | null; + instance: Instance | null; } export interface BootstrapStatus { needs_setup: boolean; - org_name?: string; + instance_name?: string; } export interface BootstrapResponse { - org: Org; + instance: Instance; slug: string; } -export interface OrgUser { +export interface InstanceUser { user_id: string; - org_id: string; + instance_id: string; email: string; role: Role; auth_source: "local" | "oidc"; @@ -426,7 +426,7 @@ export const auth = { return authRequest("/auth/bootstrap-status"); }, - bootstrap(input: { org_name: string; email: string; password: string }): Promise { + bootstrap(input: { instance_name: string; email: string; password: string }): Promise { return authRequest("/auth/bootstrap", { method: "POST", body: JSON.stringify(input), @@ -456,31 +456,31 @@ export const auth = { }; export const api = { - listOrgUsers(): Promise { - return request("/org/users"); + listInstanceUsers(): Promise { + return request("/instance/users"); }, - createOrgUser(input: OrgUserInput): Promise { - return request("/org/users", { method: "POST", body: JSON.stringify(input) }); + createInstanceUser(input: OrgUserInput): Promise { + return request("/instance/users", { method: "POST", body: JSON.stringify(input) }); }, - updateOrgUserRole(userId: string, role: Role): Promise<{ ok: boolean }> { - return request<{ ok: boolean }>(`/org/users/${userId}/role`, { + updateInstanceUserRole(userId: string, role: Role): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/instance/users/${userId}/role`, { method: "PUT", body: JSON.stringify({ role }), }); }, - deleteOrgUser(userId: string): Promise { - return request(`/org/users/${userId}`, { method: "DELETE" }); + deleteInstanceUser(userId: string): Promise { + return request(`/instance/users/${userId}`, { method: "DELETE" }); }, - getOrgOIDC(): Promise { - return request("/org/oidc"); + getInstanceOIDC(): Promise { + return request("/instance/oidc"); }, - saveOrgOIDC(input: OrgOIDCInput): Promise<{ saved: boolean }> { - return request<{ saved: boolean }>("/org/oidc", { method: "PUT", body: JSON.stringify(input) }); + saveInstanceOIDC(input: OrgOIDCInput): Promise<{ saved: boolean }> { + return request<{ saved: boolean }>("/instance/oidc", { method: "PUT", body: JSON.stringify(input) }); }, listServers(): Promise { diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 63f6ccb..3dc805c 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"fileNames":["./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/entry-constants.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/request/search-params.d.ts","./node_modules/next/dist/shared/lib/segment-cache/vary-params-decoding.d.ts","./node_modules/next/dist/server/app-render/vary-params.d.ts","./node_modules/next/dist/server/request/params.d.ts","./node_modules/next/dist/server/route-kind.d.ts","./node_modules/next/dist/server/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/route-matches/route-match.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/server/lib/cache-control.d.ts","./node_modules/next/dist/shared/lib/app-router-types.d.ts","./node_modules/next/dist/server/lib/cache-handlers/types.d.ts","./node_modules/next/dist/server/use-cache/use-cache-wrapper.d.ts","./node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","./node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/next-devtools/userspace/pages/pages-dev-overlay-setup.d.ts","./node_modules/next/dist/lib/fallback.d.ts","./node_modules/next/dist/build/static-paths/types.d.ts","./node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/build/adapter/setup-node-env.external.d.ts","./node_modules/next/dist/server/instrumentation/types.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/worker.d.ts","./node_modules/next/dist/lib/bundler.d.ts","./node_modules/next/dist/server/lib/experimental/ppr.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","./node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/server/node-environment-baseline.d.ts","./node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","./node_modules/next/dist/server/node-environment-extensions/console-file.d.ts","./node_modules/next/dist/server/node-environment-extensions/console-exit.d.ts","./node_modules/next/dist/server/node-environment-extensions/console-dim.external.d.ts","./node_modules/next/dist/server/node-environment-extensions/unhandled-rejection.external.d.ts","./node_modules/next/dist/server/node-environment-extensions/random.d.ts","./node_modules/next/dist/server/node-environment-extensions/date.d.ts","./node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/fast-set-immediate.external.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","./node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","./node_modules/next/dist/server/lib/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/client/components/readonly-url-search-params.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/client/flight-data-helpers.d.ts","./node_modules/next/dist/client/components/segment-cache/cache-key.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/client/components/segment-cache/types.d.ts","./node_modules/next/dist/shared/lib/segment-cache/segment-value-encoding.d.ts","./node_modules/next/dist/client/components/segment-cache/scheduler.d.ts","./node_modules/next/dist/client/components/segment-cache/cache-map.d.ts","./node_modules/next/dist/client/components/segment-cache/vary-path.d.ts","./node_modules/next/dist/client/components/segment-cache/cache.d.ts","./node_modules/next/dist/client/components/router-reducer/ppr-navigations.d.ts","./node_modules/next/dist/client/components/segment-cache/navigation.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/normalizers/normalizer.d.ts","./node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/after/builtin-request-context.d.ts","./node_modules/next/dist/server/normalizers/request/segment-prefix-rsc.d.ts","./node_modules/next/dist/server/route-modules/pages/builtin/_error.d.ts","./node_modules/next/dist/server/load-default-error-components.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/after/after.d.ts","./node_modules/next/dist/server/after/after-context.d.ts","./node_modules/next/dist/server/use-cache/cache-life.d.ts","./node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","./node_modules/next/dist/server/lib/lazy-result.d.ts","./node_modules/next/dist/server/app-render/create-error-handler.d.ts","./node_modules/next/dist/shared/lib/action-revalidation-kind.d.ts","./node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","./node_modules/next/dist/server/async-storage/work-store.d.ts","./node_modules/next/dist/server/web/http.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect-error.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/server/app-render/cache-signal.d.ts","./node_modules/next/dist/server/app-render/instant-validation/boundary-tracking.d.ts","./node_modules/next/dist/server/app-render/instant-validation/instant-validation-error.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","./node_modules/next/dist/server/app-render/instant-validation/instant-samples.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","./node_modules/next/dist/server/lib/implicit-tags.d.ts","./node_modules/next/dist/server/app-render/staged-rendering.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","./node_modules/next/dist/build/templates/app-route.d.ts","./node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","./node_modules/next/dist/build/segment-config/app/app-segments.d.ts","./node_modules/next/dist/build/get-supported-browsers.d.ts","./node_modules/next/dist/build/utils.d.ts","./node_modules/next/dist/build/rendering-mode.d.ts","./node_modules/next/dist/server/lib/router-utils/build-prefetch-segment-data-route.d.ts","./node_modules/next/dist/server/lib/cpu-profile.d.ts","./node_modules/next/dist/build/turborepo-access-trace/types.d.ts","./node_modules/next/dist/build/turborepo-access-trace/result.d.ts","./node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","./node_modules/next/dist/build/turborepo-access-trace/index.d.ts","./node_modules/next/dist/export/routes/types.d.ts","./node_modules/next/dist/export/types.d.ts","./node_modules/next/dist/export/worker.d.ts","./node_modules/next/dist/build/worker.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/use-cache-tracker-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/telemetry-plugin.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/build/build-context.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/swc/generated-native.d.ts","./node_modules/next/dist/build/define-env.d.ts","./node_modules/next/dist/build/swc/index.d.ts","./node_modules/next/dist/build/swc/types.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/next-devtools/shared/types.d.ts","./node_modules/next/dist/server/dev/dev-indicator-server-state.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/cache-indicator.d.ts","./node_modules/next/dist/server/lib/parse-stack.d.ts","./node_modules/next/dist/next-devtools/server/shared.d.ts","./node_modules/next/dist/next-devtools/shared/stack-frame.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/utils/get-error-by-type.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/container/runtime-error/render-error.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/shared.d.ts","./node_modules/next/dist/server/dev/debug-channel.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/server/lib/async-callback-set.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/sharp/lib/index.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/lru-cache.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/router-utils/router-server-context.d.ts","./node_modules/next/dist/server/route-modules/route-module.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/server/web/adapter.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/client-segment.d.ts","./node_modules/next/dist/client/components/http-access-fallback/error-boundary.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/dist/lib/metadata/types/resolvers.d.ts","./node_modules/next/dist/lib/metadata/types/icons.d.ts","./node_modules/next/dist/lib/metadata/resolve-metadata.d.ts","./node_modules/next/dist/lib/metadata/metadata.d.ts","./node_modules/next/dist/lib/framework/boundary-components.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/collect-segment-data.d.ts","./node_modules/next/dist/server/app-render/instant-validation/instant-validation.d.ts","./node_modules/next/dist/next-devtools/userspace/app/segment-explorer-node.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/route-modules/app-page/helpers/prerender-manifest-matcher.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react/jsx-dev-runtime.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/rsc/entrypoints.d.ts","./node_modules/@types/react-dom/client.d.ts","./node_modules/@types/react-dom/server.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/ssr/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/request/fallback-params.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/server/web/spec-extension/url-pattern.d.ts","./node_modules/next/dist/server/after/index.d.ts","./node_modules/next/dist/server/request/connection.d.ts","./node_modules/next/dist/server/web/exports/index.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/cli/next-test.d.ts","./node_modules/next/dist/shared/lib/size-limit.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/build/adapter/build-complete.d.ts","./node_modules/next/dist/types.d.ts","./node_modules/next/types.d.ts","./node_modules/next/index.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image-types/global.d.ts","./.next/types/routes.d.ts","./next-env.d.ts","./next.config.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/tailwindcss/types/generated/corepluginlist.d.ts","./node_modules/tailwindcss/types/generated/colors.d.ts","./node_modules/tailwindcss/types/config.d.ts","./node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","./node_modules/clsx/clsx.d.mts","./components/ui/button.tsx","./components/ui/badge.tsx","./components/ui/card.tsx","./components/ui/table.tsx","./components/ui/modal.tsx","./components/ui/index.ts","./lib/api.ts","./lib/guacconsole.ts","./node_modules/@tanstack/query-core/build/modern/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/query-core/build/modern/index.d.ts","./node_modules/@tanstack/react-query/build/modern/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/react-query/build/modern/index.d.ts","./lib/query-client.ts","./components/providers.tsx","./app/layout.tsx","./components/authprovider.tsx","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/unrecognized-action-error.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/forbidden.d.ts","./node_modules/next/dist/client/components/unauthorized.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.server.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./components/logo.tsx","./components/sidebar.tsx","./app/(app)/layout.tsx","./app/(app)/page.tsx","./app/(app)/audit/page.tsx","./app/(app)/keys/page.tsx","./app/(app)/keys/[id]/page.tsx","./app/(app)/monitors/page.tsx","./app/(app)/monitors/[id]/page.tsx","./components/monitors/monitorform.tsx","./app/(app)/monitors/[id]/edit/page.tsx","./app/(app)/monitors/new/page.tsx","./app/(app)/secrets/page.tsx","./app/(app)/secrets/[group]/page.tsx","./app/(app)/servers/page.tsx","./app/(app)/servers/[id]/page.tsx","./app/(app)/servers/[id]/console/page.tsx","./app/(app)/servers/new/page.tsx","./app/(app)/settings/page.tsx","./app/(app)/settings/notifications/page.tsx","./app/(app)/settings/org/page.tsx","./components/workflows/editstepmodal.tsx","./app/(app)/steps/page.tsx","./app/(app)/workflows/page.tsx","./components/workflows/editworkflowmodal.tsx","./components/workflows/steppickermodal.tsx","./app/(app)/workflows/[id]/page.tsx","./app/(app)/workflows/[id]/runs/page.tsx","./app/(app)/workflows/[id]/runs/[runid]/page.tsx","./components/networkbackground.tsx","./app/login/page.tsx","./app/setup/page.tsx","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/dist/server/use-cache/cache-tag.d.ts","./.next/types/cache-life.d.ts","./.next/types/validator.ts","./node_modules/@types/estree/index.d.ts","./node_modules/@types/json-schema/index.d.ts","./node_modules/@types/json5/index.d.ts","./node_modules/@types/node/index.d.ts"],"fileIdsList":[[386,387,388,389],[39,291,297,340,356,357,358,359,360,361,362,364,365,366,367,368,369,370,371,372,373,374,376,377,380,381,382,384,385],[39,331,332,337],[10,39,331,332,337,343,353],[10,39,331,332,337,343],[39,341,355],[39,331,332,337,343,353,363],[39,331,332,337,343],[39,353],[10,39,331,332,333,337,343,353],[10,39,331,332,337],[10,39,331,332,337,341],[10,39,331,332,337,341,343],[10,39,331,332,337,375],[10,39,331,332,337,343,353,378,379],[10,39,331,332,337,353],[39,331,332,337,343,353],[39,292,339],[10,39,331,332,337,354,383],[10,39,332],[39],[10,39],[39,337,338],[39,325,332,341,343,353,354],[39,325],[10,39,325],[39,326,327,328,329,330],[39,337],[292,296,297],[39,292],[334],[10,39,335],[336],[10],[10,272],[8,9],[14,15,29,48,55,168,179,286],[15,50,51,52,54,286],[15,18,185,187,189,190,192,286],[15,53,88,286],[15,18,28,29,35,42,47,48,167,168,169,178,286],[286],[24,30,51,71,164],[15],[24,30,41],[196],[193,194,196],[193,195,286],[71,266,283],[140,143,159,164,283],[112,283],[172],[171,172,173],[171],[4,15,29,35,41,42,49,51,55,56,69,70,135,165,166,179,286,290],[5,14,15,53,88,185,186,191,286],[5,53],[5,14,70,237,286],[5],[5,15,53,54],[5,188],[56,167,170,177],[24,39],[10,109],[10,30,39,242],[24,95,109,110,344,351],[94,345,346,347,348,350],[145],[145,146],[28,30,97,98],[30,104,105],[30,99,107],[104],[22,30,97,98,99,100,101,102,103,104,107],[30,97,104,105,106,108],[30,98,100,101],[98,100,103,105],[349],[30],[10,16,293],[10,53,86],[10,53,179],[84,89],[10,85,288],[19,29,34,115,132,174,175,179,234,236,286],[69,176],[290],[287],[10,21,24,239,255,257],[24,239,254,255,256],[248,249,250,251,252,253],[250],[254],[39,203,204,206],[10,30,197,198,199,200,205],[203,205],[201],[202],[132,133],[133],[19,288],[162],[161],[19,24,30,36,38,140,153,157,159,236,239,275,276,283],[30,80,101],[140,151,154,159],[10,21,24,140,143,159,162,196,243,244,245,246,247,258,259,260,261,262,263,264,265],[21,24,51,140,147,148,149,152,153],[30,51,151,158,239,240,283],[155],[6,7,16,18,19,30,34,45,78,132,135,200,234,235,275,286,290],[21,22,24],[140],[7,19,51,78,134,135,136,137,138,139],[159],[23,24,34,38,76,140,147,148,149,150,151,154,155,156,157,158,276],[18,19,76,77,147],[19,51,78,132,135,140,236],[18,286],[18,19,283],[6,7,18,19,24,29,36,38,41,42,45,53,73,78,79,80,115,116,118,121,123,126,127,128,129,131,179,234,236,283,286],[5,15,16,17,49,283,284,285,288,290],[14,48,286],[208],[5,26,192,196,197,198,199,200,206,207],[24,26,38,41,42,78,116,121,131,132,185,212,213,214,220,223,224,234,236,283,286],[42,49,56,69,78,135,286],[16,29,38,78,218,283,286],[238],[208,221,222,231],[283,286],[137,276],[7,38,179,288],[121,181,185,214,220,223,226,283,287],[56,69,185,227],[15,18,79,179,229,286],[200,286],[53,79,179,180,181,190,208,228,230,286],[4,7,233,288,290],[130,234],[6,24,27,29,30,36,38,45,55,56,69,78,116,118,128,131,132,179,212,213,214,215,217,219,234,236,283,288],[56,220,225,231,283],[59,60,61,62,63,64,65,66,67,68],[73,122],[124],[122],[124,125],[19,28,29,30,34,35],[6,7,16,18,36,40,80,114,234,283,287,288,290],[19,20,27,28,38,40,78,232,276,282],[30,42,275],[23],[25,37],[25,29,36],[32,37],[33],[25,26],[25,81],[25],[27,73,120],[119],[24,26,27],[27,117],[24,26],[7,179],[275],[7,19,36,38,43,179,233,236,239,240,241,267,268,271,274,276,283],[90,93,95,96,109,110],[10,39,269,270],[10,39,269,270,273],[163],[7,18,51,72,77,140,141,142,143,144,146,159,160,162,165,233,236,286],[109],[114,283],[114],[36,82,111,113,115,233,283,288,290],[11,90,91,92,93,95,96,109,110],[4,6,7,19,25,26,38,45,78,179,231,232,234,283,286,290],[21,24,31],[77,78,209,212],[77,210,277,278,279,280,281],[73,286],[76,159],[75],[77,128],[74,76,286],[19,20,77,209,210,211,283,286],[10,24,30,108],[10,22],[12,13],[10,16],[10,24,94],[4,6,7,10,288,290],[16,293,294],[10,89],[10,83,85,87,88,287,288],[19,24,53],[24,216],[10,11,14,89,187,287,290],[182,183,184],[182],[10,18,41,45,51,226,254,287,288,289],[295],[291],[342],[352],[315],[313,315],[304,312,313,314,316,318],[302],[305,310,315,318],[301,318],[305,306,309,310,311,318],[305,306,307,309,310,318],[302,303,304,305,306,310,311,312,314,315,316,318],[318],[300,302,303,304,305,306,307,309,310,311,312,313,314,315,316,317],[300,318],[305,307,308,310,311,318],[309,318],[310,311,315,318],[303,313],[320,321],[319,322],[39,323]],"fileInfos":[{"version":"2a0e2ab8f24b2eccbbb26f0a0fe1bb8daf4860165ee3ab98ebefe8439fda9066","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"932d976396286feabcb3424caf9110a4dbb0142a2a0692473a4ec0bfe82d6a43","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"8f96f1564928960b219aec5f1b8a4847ad13ec491eaaf38b5c84dd8479f9f454","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"21da358700a3893281ce0c517a7a30cbd46be020d9f0c3f2834d0a8ad1f5fc75","signature":false,"impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","signature":false,"impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","signature":false,"impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","signature":false,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","signature":false,"impliedFormat":1},{"version":"0cac6cc7a3fd0009a80d8fe45b17575ba4b48b0257ac4c021086ba6d40362a56","signature":false,"impliedFormat":1},{"version":"767ba5dd488ee95c59fedc15b58e1629b8401f28c52669bbbdf62ef2dc6806c4","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"e8daa443eaf9a27fd382cc1f8ebe30330c0f4d89511cfb469166874806751d35","signature":false,"impliedFormat":1},{"version":"446a50749b24d14deac6f8843e057a6355dd6437d1fac4f9e5ce4a5071f34bff","signature":false,"impliedFormat":1},{"version":"182e9fcbe08ac7c012e0a6e2b5798b4352470be29a64fdc114d23c2bab7d5106","signature":false,"impliedFormat":1},{"version":"2f4e6b4d39426a1b85ecf4bdeb9dddbf4d9b3397d95d8555d46f925c9519ec7d","signature":false,"impliedFormat":1},{"version":"fd4e24ccff3966390600d7f5d6aa1fed5a512e92ada735ea5fbc933d313ad3d3","signature":false,"impliedFormat":1},{"version":"b7cddfe1aa6b86b5fad3c9ccb30d05b3ccb165aebbf112f48d2d8a5f69dd98b1","signature":false,"impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","signature":false,"impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","signature":false,"impliedFormat":1},{"version":"35e6379c3f7cb27b111ad4c1aa69538fd8e788ab737b8ff7596a1b40e96f4f90","signature":false,"impliedFormat":1},{"version":"ad0d1d75d129b1c80f911be438d6b61bfa8703930a8ff2be2f0e1f8a91841c64","signature":false,"impliedFormat":1},{"version":"bd2c7ada3dee03653d3f601011d30072194bc3970cd93208f9588fbdc0c69347","signature":false,"impliedFormat":1},{"version":"e480da45d32313e7174b265674da504f075f59ef326852f0c5a5d863b438ae85","signature":false,"impliedFormat":1},{"version":"ad54850f61fcf5d014e11be80d2f46fea9265cfa7e77456da876f7833ef81769","signature":false,"impliedFormat":1},{"version":"6f7c9e8bd2b5b6a080b07080065f94900bd3c7e5ebbd3047bc33fcce2fab1dd8","signature":false,"impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","signature":false,"impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","signature":false,"impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","signature":false,"impliedFormat":1},{"version":"da5950ee2a90721df6f3fba45f5d05308f7e4c35835392215dd2cd404505e2de","signature":false,"impliedFormat":1},{"version":"ce75b1aebb33d510ff28af960a9221410a3eaf7f18fc5f21f9404075fba77256","signature":false,"impliedFormat":1},{"version":"f42d5fed19610d485c646a0c430e768115567d078c7fc855c57b0c578b3d6cd3","signature":false,"impliedFormat":1},{"version":"ee8df1cb8d0faaca4013a1b442e99130769ce06f438d18d510fed95890067563","signature":false,"impliedFormat":1},{"version":"d5630f2ad9b4541e5ce891648121022f9412ecdca1820baa1f0104f70fd7eff7","signature":false,"impliedFormat":1},{"version":"4d15375ab13497104bc8fe56fdef2b5fd6853f29255737d23a33fa306ff7fd69","signature":false,"impliedFormat":1},{"version":"2cd3fc1d0d6a1e85baffd2d4f50f5efb192b5446eef567e97c94765402f0aad4","signature":false,"impliedFormat":1},{"version":"e4cbf2f1e89ecccaddd2c045e600ae41b732295953fb06247c7dcbc2d281ed30","signature":false,"impliedFormat":1},{"version":"6dcedaef57dff0d79a05ab0ab602cde74db803d1e765468bf91263786a383e1b","signature":false,"impliedFormat":1},{"version":"8c1697d90c394a6fd955b98eae01238eff628e129b987a68aea10f898a48e7da","signature":false,"impliedFormat":1},{"version":"7580e62139cb2b44a0270c8d01abcbfcba2819a02514a527342447fa69b34ef1","signature":false,"impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","signature":false,"impliedFormat":1},{"version":"f374cb24e93e7798c4d9e83ff872fa52d2cdb36306392b840a6ddf46cb925cb6","signature":false,"impliedFormat":1},{"version":"413df52d4ea14472c2fa5bee62f7a40abd1eb49be0b9722ee01ee4e52e63beb2","signature":false,"impliedFormat":1},{"version":"d10d63718e1646c2279e3b33831f82c60e31f622b2b7020f1196409ca4c09242","signature":false,"impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","signature":false,"impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","signature":false,"impliedFormat":1},{"version":"148679c6d0f449210a96e7d2e562d589e56fcde87f843a92808b3ff103f1a774","signature":false,"impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","signature":false,"impliedFormat":1},{"version":"02436d7e9ead85e09a2f8e27d5f47d9464bced31738dec138ca735390815c9f0","signature":false,"impliedFormat":1},{"version":"78a2869ad0cbf3f9045dda08c0d4562b7e1b2bfe07b19e0db072f5c3c56e9584","signature":false,"impliedFormat":1},{"version":"f8d5ff8eafd37499f2b6a98659dd9b45a321de186b8db6b6142faed0fea3de77","signature":false,"impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","signature":false,"impliedFormat":1},{"version":"a22dd55aa4d39906252000ab8e8a1b83b195eef7f4274eb51e457c1f11cf6580","signature":false,"impliedFormat":1},{"version":"540cc83ab772a2c6bc509fe1354f314825b5dba3669efdfbe4693ecd3048e34f","signature":false,"impliedFormat":1},{"version":"121b0696021ab885c570bbeb331be8ad82c6efe2f3b93a6e63874901bebc13e3","signature":false,"impliedFormat":1},{"version":"612d9da66bb046a9c1e2e8d026245ded881fc4b9f98cbfae714415d57ee0ae0b","signature":false,"impliedFormat":1},{"version":"32c2ad9494dad5d11b0564a619fee18f388db6c1e9e2cd3c360b3122549691eb","signature":false,"impliedFormat":1},{"version":"6c301d40aec56a74ec7bd7324e31a728dadf9bfba3e96def02938d3d973534ec","signature":false,"impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","signature":false,"impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","signature":false,"impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","signature":false,"impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","signature":false,"impliedFormat":1},{"version":"aa14cee20aa0db79f8df101fc027d929aec10feb5b8a8da3b9af3895d05b7ba2","signature":false,"impliedFormat":1},{"version":"493c700ac3bd317177b2eb913805c87fe60d4e8af4fb39c41f04ba81fae7e170","signature":false,"impliedFormat":1},{"version":"aeb554d876c6b8c818da2e118d8b11e1e559adbe6bf606cc9a611c1b6c09f670","signature":false,"impliedFormat":1},{"version":"acf5a2ac47b59ca07afa9abbd2b31d001bf7448b041927befae2ea5b1951d9f9","signature":false,"impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","signature":false,"impliedFormat":1},{"version":"d71291eff1e19d8762a908ba947e891af44749f3a2cbc5bd2ec4b72f72ea795f","signature":false,"impliedFormat":1},{"version":"c0480e03db4b816dff2682b347c95f2177699525c54e7e6f6aa8ded890b76be7","signature":false,"impliedFormat":1},{"version":"25a5f6fd3a2243c859eddc99ab5fba11d970af2fe7a5df9c32b7668f76f97b01","signature":false,"impliedFormat":1},{"version":"8d207e1f9d2c30d6f77dfa693f3827c3fbf0d89240297e10bdfe1041d433df68","signature":false,"impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","signature":false,"impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","signature":false,"impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","signature":false,"impliedFormat":1},{"version":"d682336018141807fb602709e2d95a192828fcb8d5ba06dda3833a8ea98f69e3","signature":false,"impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","signature":false,"impliedFormat":1},{"version":"0d891735a21edc75df51f3eb995e18149e119d1ce22fd40db2b260c5960b914e","signature":false,"impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","signature":false,"impliedFormat":1},{"version":"4fbd3116e00ed3a6410499924b6403cc9367fdca303e34838129b328058ede40","signature":false,"impliedFormat":1},{"version":"8c70ddc0c22d85e56011d49fddfaae3405eb53d47b59327b9dd589e82df672e7","signature":false,"impliedFormat":1},{"version":"2f9c89cbb29d362290531b48880a4024f258c6033aaeb7e59fbc62db26819650","signature":false,"impliedFormat":1},{"version":"a365c4d3bed3be4e4e20793c999c51f5cd7e6792322f14650949d827fbcd170f","signature":false,"impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","signature":false,"impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","signature":false,"impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","signature":false,"impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","signature":false,"impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","signature":false,"impliedFormat":1},{"version":"273782b8454e78f6a8b30d2cfbf6860499c930595095fcc1689637115f0eddda","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"3fbdd025f9d4d820414417eeb4107ffa0078d454a033b506e22d3a23bc3d9c41","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","signature":false,"impliedFormat":1},{"version":"a8f8e6ab2fa07b45251f403548b78eaf2022f3c2254df3dc186cb2671fe4996d","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","signature":false,"impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","signature":false,"impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","signature":false,"impliedFormat":1},{"version":"9f9bb6755a8ce32d656ffa4763a8144aa4f274d6b69b59d7c32811031467216e","signature":false,"impliedFormat":1},{"version":"5c32bdfbd2d65e8fffbb9fbda04d7165e9181b08dad61154961852366deb7540","signature":false,"impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","signature":false,"impliedFormat":1},{"version":"0c05e9842ec4f8b7bfebfd3ca61604bb8c914ba8da9b5337c4f25da427a005f2","signature":false,"impliedFormat":1},{"version":"faed7a5153215dbd6ebe76dfdcc0af0cfe760f7362bed43284be544308b114cf","signature":false,"impliedFormat":1},{"version":"7029e566b8df176f703fb59fd437a38670c7a0e02c58b2d66dfb5b2e2b2defdb","signature":false,"impliedFormat":1},{"version":"7f2aa4d4989a82530aaac3f72b3dceca90e9c25bee0b1a327e8a08a1262435ad","signature":false,"impliedFormat":1},{"version":"d96b39301d0ded3f1a27b47759676a33a02f6f5049bfcbde81e533fd10f50dcb","signature":false,"impliedFormat":1},{"version":"e9f147ecca73d9346a4c073432843c159ccbe50bdcb678a78f6da10eae2cecf4","signature":false,"impliedFormat":1},{"version":"d9288f7fb2f3e22a139f3da91fca4aa4977019fe2caa5ca70bf0ce3ef9d94fb7","signature":false,"impliedFormat":1},{"version":"663beafc2446079574570cba86e9b15f986f908ddb1b01274509970126fee945","signature":false,"impliedFormat":1},{"version":"a3102887d5058bf4cb5b37fa6964c09e9527c42053b3b5c642b89878620748de","signature":false,"impliedFormat":1},{"version":"0aaaa1727edd29673d85c9b26d7ca4d54e5407a48586903c51b48b7f7d196f61","signature":false,"impliedFormat":1},{"version":"d35bca0b261bff02635758c48e8ab99c61c420d0dfabbcf467e847171d876b7d","signature":false,"impliedFormat":1},{"version":"3bc12c40d90c342ff88a3d876996c555ed5cbee5fe8c3308a240b321f401ee46","signature":false,"impliedFormat":1},{"version":"ba130768aae855a5477e9e148e5c879548e6e7ccbcc56fd1934c8a18ea5b7569","signature":false,"impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","signature":false,"impliedFormat":1},{"version":"d38530db0601215d6d767f280e3a3c54b2a83b709e8d9001acb6f61c67e965fc","signature":false,"impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","signature":false,"impliedFormat":1},{"version":"b499af2054a037a162b3b72cd886f48bbf32a3502c865c6e29fac7d2ab3ce0b5","signature":false,"impliedFormat":1},{"version":"b83cb14474fa60c5f3ec660146b97d122f0735627f80d82dd03e8caa39b4388c","signature":false,"impliedFormat":1},{"version":"48773ca557b0319c2ee62ae249cf52a81709e8be139920d6479a66274de7c4ed","signature":false,"impliedFormat":1},{"version":"7274fbffbd7c9589d8d0ffba68157237afd5cecff1e99881ea3399127e60572f","signature":false,"impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","signature":false,"impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","signature":false,"impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","signature":false,"impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","signature":false,"impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","signature":false,"impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","signature":false,"impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","signature":false,"impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","signature":false,"impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","signature":false,"impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","signature":false,"impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","signature":false,"impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","signature":false,"impliedFormat":1},{"version":"8b4327413e5af38cd8cb97c59f48c3c866015d5d642f28518e3a891c469f240e","signature":false,"impliedFormat":1},{"version":"4cceef18d7f088e797a463e90b7a9dad10c6bc667724b7686e3e740ae00122be","signature":false,"impliedFormat":1},{"version":"7ee86fbb3754388e004de0ef9e6505485ddfb3be7640783d6d015711c03d302d","signature":false,"impliedFormat":1},{"version":"cc1954b539604b1e562319119ac7e888172208b32ca873f9a357a92c826bd046","signature":false,"impliedFormat":1},{"version":"a67b87d0281c97dfc1197ef28dfe397fc2c865ccd41f7e32b53f647184cc7307","signature":false,"impliedFormat":1},{"version":"771ffb773f1ddd562492a6b9aaca648192ac3f056f0e1d997678ff97dbb6bf9b","signature":false,"impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","signature":false,"impliedFormat":1},{"version":"232f70c0cf2b432f3a6e56a8dc3417103eb162292a9fd376d51a3a9ea5fbbf6f","signature":false,"impliedFormat":1},{"version":"bb8f2dbc03533abca2066ce4655c119bff353dd4514375beb93c08590c03e023","signature":false,"impliedFormat":1},{"version":"706dd95827e7ebaabda91d5db2b755233e0952d98570e9c032b0f066a15c1177","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"0b103e9abfe82d14c0ad06a55d9f91d6747154ef7cacc73cf27ecad2bfb3afcf","signature":false,"impliedFormat":1},{"version":"cd9304972e6d616197fb44fce00540a904f38b54306a1951b5dbeaf3c01ab5bd","signature":false,"impliedFormat":1},{"version":"77438e2c397a3db78407621cfc57241a305b310ddea2c185f1d555248297f587","signature":false,"impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","signature":false,"impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","signature":false,"impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","signature":false,"impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","signature":false,"impliedFormat":1},{"version":"a6805fcafed712aea7759f8bc731014f9d22738c1d6ef9d43b8091d1d48346d5","signature":false,"impliedFormat":1},{"version":"c49469a5349b3cc1965710b5b0f98ed6c028686aa8450bcb3796728873eb923e","signature":false,"impliedFormat":1},{"version":"4a889f2c763edb4d55cb624257272ac10d04a1cad2ed2948b10ed4a7fda2a428","signature":false,"impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","signature":false,"impliedFormat":1},{"version":"d88ea80a6447d7391f52352ec97e56b52ebec934a4a4af6e2464cfd8b39c3ba8","signature":false,"impliedFormat":1},{"version":"142617b3cdf902b69c6464c9fbd942b60ab3e733ca18c032b19e0f7e2adbefe8","signature":false,"impliedFormat":1},{"version":"0b603555f1881f87256ffd6344d3e3ed6d466c2e701eabf381f28be8c2125892","signature":false,"impliedFormat":1},{"version":"897e4f7662488e3ecc79e743bdd3b78f13bdb69a97851afa5b440c4211e32ea9","signature":false,"impliedFormat":1},{"version":"e2e1c6d3b2d93add5200bd7bc1a8cccb4e446836b2111ece45db8683a2c765de","signature":false,"impliedFormat":1},{"version":"251b03d5cd243854ce870d9a9a39f491faf69898c5d6b5eee28cc7649c57417b","signature":false,"impliedFormat":1},{"version":"27ff4196654e6373c9af16b6165120e2dd2169f9ad6abb5c935af5abd8c7938c","signature":false,"impliedFormat":1},{"version":"2c4de79f406d137390608e8c0a44fba2ff8e00bacfcae7c9d1781fef10e9440d","signature":false,"impliedFormat":1},{"version":"07ba23a10465791be5d22deaf5ef7de7658774ddff53721e5ea17fedea1bc721","signature":false,"impliedFormat":1},{"version":"dca8c645c5afeb03b1ecedbf16323f33e7d0afaa6256c8e047e6e38087a97f53","signature":false,"impliedFormat":1},{"version":"775f181bd4a533d6f8b5e55ec1d9f1624559720ae8a70e9432258da26b38d27c","signature":false,"impliedFormat":1},{"version":"796273b2edc72e78a04e86d7c58ae94d370ab93a0ddf40b1aa85a37a1c29ecd7","signature":false,"impliedFormat":1},{"version":"5df15a69187d737d6d8d066e189ae4f97e41f4d53712a46b2710ff9f8563ec9f","signature":false,"impliedFormat":1},{"version":"7715134a0cf07dd41a9da2895d708625a3a303a0385e355ecaaf0b8bfaef2550","signature":false,"impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","signature":false,"impliedFormat":1},{"version":"622694a8522b46f6310c2a9b5d2530dde1e2854cb5829354e6d1ff8f371cf469","signature":false,"impliedFormat":1},{"version":"cd8ce8d68567f62dd580b3c3c37777ac3f5b81944c7417f5ea83030eab533385","signature":false,"impliedFormat":1},{"version":"e5c939d896565dcac0f6fbdbada11284e7728ef26a069561c09aa5aa4a788393","signature":false,"impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","signature":false,"impliedFormat":1},{"version":"a9e6c0ff3f8186fccd05752cf75fc94e147c02645087ac6de5cc16403323d870","signature":false,"impliedFormat":1},{"version":"49af4b52f0d4d2304c5f2c6fe5fab3e153e0acc38830d0202821b877c097dd02","signature":false,"impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","signature":false,"impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","signature":false,"impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","signature":false,"impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","signature":false,"impliedFormat":1},{"version":"e68b8e5a1df7c1be2bc105141456ecba70215806e1c28bfbc5c12bfce4be6e68","signature":false,"impliedFormat":1},{"version":"511c8f02329808d47d00b859c532ae9115590048b17325a946c74dac48428650","signature":false,"impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","signature":false,"impliedFormat":1},{"version":"b5f9e66625783eefcbe3d2da074b2e7ba2066d61ce3fc6ef4f22805ad946cab4","signature":false,"impliedFormat":1},{"version":"e37115962d284b9f7a37c2bdd2add50f88365dde41f5e0ff591ffc48a8ec7575","signature":false,"impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","signature":false,"impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","signature":false,"impliedFormat":1},{"version":"f89488602bec98a142072fae7ea5ba99431a569ff580c64b7be39896474799d8","signature":false,"impliedFormat":1},{"version":"bbbc47961f39a57df103cf4ca3bb8f8732b4b6678a18225a0aa76d59c466956c","signature":false,"impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","signature":false,"impliedFormat":1},{"version":"2ffb043dc5163458e473b7010859f86e01dc4edffcae0a93d885d028b426a546","signature":false,"impliedFormat":1},{"version":"c8f004e6036aa1c764ad4ec543cf89a5c1893a9535c80ef3f2b653e370de45e6","signature":false,"impliedFormat":1},{"version":"dd80b1e600d00f5c6a6ba23f455b84a7db121219e68f89f10552c54ba46e4dc9","signature":false,"impliedFormat":1},{"version":"623881c99bfecc0e1774927f2ad1f5632189904886b04ad9f810a732388726d8","signature":false,"impliedFormat":1},{"version":"05c7280d72f3ed26f346cbe7cbbbb002fb7f15739197cbbee6ab3fd1a6cb9347","signature":false,"impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","signature":false,"impliedFormat":1},{"version":"04b7b2e0832dfd3c31e81df3975e8d8fda28e7ff999b0aa2932608a8f6661d5c","signature":false,"impliedFormat":1},{"version":"ca2d34c6ed5cbd3070b8b6f32f42ae54adcc6499c1e4b99f0a5798b3f27cc653","signature":false,"impliedFormat":1},{"version":"286e26d79b2dbf8b447c41fcd323d073876589e4dcc587c915b2d9c3699808e4","signature":false,"impliedFormat":1},{"version":"5c4d626b4902f2ef8a1cc146d761d276cef988016dc674e3b98fbad70e64bc9f","signature":false,"impliedFormat":1},{"version":"fdfaa0aad899524962e2955287b5b991ffe3be50f64e02eb60c933ca44644a94","signature":false,"impliedFormat":1},{"version":"53c972a0f9bc3a4ec70fff7314123ea8cfcf75b3703046f767d2dc1eea87b2fb","signature":false,"impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","signature":false,"impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","signature":false,"impliedFormat":1},{"version":"7d73b24e7bf31dfb8a931ca6c4245f6bb0814dfae17e4b60c9e194a631fe5f7b","signature":false,"impliedFormat":1},{"version":"d130c5f73768de51402351d5dc7d1b36eaec980ca697846e53156e4ea9911476","signature":false,"impliedFormat":1},{"version":"413586add0cfe7369b64979d4ec2ed56c3f771c0667fbde1bf1f10063ede0b08","signature":false,"impliedFormat":1},{"version":"06472528e998d152375ad3bd8ebcb69ff4694fd8d2effaf60a9d9f25a37a097a","signature":false,"impliedFormat":1},{"version":"7303b45138d2511035056a5901a1490ebdcbf055cbb1276f8629c5121cbe733e","signature":false,"impliedFormat":1},{"version":"27f874cd5327507eeff699a74567f60c1215b94509f4308633a7b01922471ed2","signature":false,"impliedFormat":1},{"version":"a401617604fa1f6ce437b81689563dfdc377069e4c58465dbd8d16069aede0a5","signature":false,"impliedFormat":1},{"version":"2c6cf04bc525caf6546e859e8ef10bfb9573837ec0bc5ec7b53a7b1b8ca72781","signature":false,"impliedFormat":1},{"version":"8695dec09ad439b0ceef3776ea68a232e381135b516878f0901ed2ea114fd0fe","signature":false,"impliedFormat":1},{"version":"304b44b1e97dd4c94697c3313df89a578dca4930a104454c99863f1784a54357","signature":false,"impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","signature":false,"impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","signature":false,"impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","signature":false,"impliedFormat":1},{"version":"87cc05fe13108f02e12da7e3efd8e360fef78d96a0c9e11408ea1b1b9fb3e03d","signature":false,"impliedFormat":1},{"version":"1abbf67c218d23c2ce76887caac2df6c7dab3d97ba2b65348432b876f510002a","signature":false,"impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","signature":false,"impliedFormat":1},{"version":"4b20fcf10a5413680e39f5666464859fc56b1003e7dfe2405ced82371ebd49b6","signature":false,"impliedFormat":1},{"version":"c06ef3b2569b1c1ad99fcd7fe5fba8d466e2619da5375dfa940a94e0feea899b","signature":false,"impliedFormat":1},{"version":"f7d628893c9fa52ba3ab01bcb5e79191636c4331ee5667ecc6373cbccff8ae12","signature":false,"impliedFormat":1},{"version":"930f2e90e26084627830e5e2e43cc44a3876df99fdeae21d32c3d28c8375aa74","signature":false,"impliedFormat":1},{"version":"dad97c99382889e9c7d1a9d8275500ff71235130fae9f8916fdbf3641d56e592","signature":false,"impliedFormat":1},{"version":"a6dba407fc287f1e25454e75028c91bbc00675f2d1c4e8b3edcc36c08611a486","signature":false,"impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","signature":false,"impliedFormat":1},{"version":"e91f7b1344577a02f051b9b471f33044fef8334a76dc9e1de003d17595a5219b","signature":false,"impliedFormat":1},{"version":"c0723195c85e19656d6b5b9fdb81d3f3403c1ae4679e722c6ea058c516b38d12","signature":false,"impliedFormat":1},{"version":"b55eb9f72166093b5460d34b34f5d8699c968de3bc3fc696e40f2c93f2ebf650","signature":false,"impliedFormat":1},{"version":"71d9eb4c4e99456b78ae182fb20a5dfc20eb1667f091dbb9335b3c017dd1c783","signature":false,"impliedFormat":1},{"version":"cfa846a7b7847a1d973605fbb8c91f47f3a0f0643c18ac05c47077ebc72e71c7","signature":false,"impliedFormat":1},{"version":"1594da19968752a22b2ac48c2d0e60575700e745c577a8a4a676b841238ad5bb","signature":false,"impliedFormat":1},{"version":"e0cee12109e0a10a4c3d6769fcc7644b7c1ea7f52365bea51728f5af29f8a137","signature":false,"impliedFormat":1},{"version":"7d4254b4c6c67a29d5e7f65e67d72540480ac2cfb041ca484847f5ae70480b62","signature":false,"impliedFormat":1},{"version":"3536968defef8a75514f547ead5e2e9c1e984820290ec9b00c5fdfb6ef786535","signature":false,"impliedFormat":1},{"version":"d83773870080c30a230e322ce13a9c6f3398e8dacea4ea8a83e26370f3bac23e","signature":false,"impliedFormat":1},{"version":"dcfeaf98d66314fec29a9076c4290e45d0b196a65827becc19138e9c7b855f37","signature":false,"impliedFormat":1},{"version":"6849fe9210fe4946d5f085bfed36758f33dc6ae15a751338d178dd4daa017c46","signature":false,"impliedFormat":1},{"version":"888cda0fa66d7f74e985a3f7b1af1f64b8ff03eb3d5e80d051c3cbdeb7f32ab7","signature":false,"impliedFormat":1},{"version":"60681e13f3545be5e9477acb752b741eae6eaf4cc01658a25ec05bff8b82a2ef","signature":false,"impliedFormat":1},{"version":"ffae4e1e06aa848a1e4bcef162cd1c48e5909b26223515981310af9c036bdfc7","signature":false,"impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","signature":false,"impliedFormat":1},{"version":"34e16eb7c31768a11a08aebcfb3d70d7b8f0b016197e98d8419e566ceae6d6c8","signature":false,"impliedFormat":1},{"version":"f94ec1f7e4b709d26960306c9082a7a1b728a6e13089346aa48ba57c74cbf47e","signature":false,"impliedFormat":1},{"version":"9a11cb4033405e96c247cd5aa29790212aaffdd127869e8a5219103f0b389fd5","signature":false,"impliedFormat":1},{"version":"01479d9d5a5dda16d529b91811375187f61a06e74be294a35ecce77e0b9e8d6c","signature":false,"impliedFormat":1},{"version":"aff5213585cb72e94054dfe17250ff315f3569b3919d1ef1ad235f37c4ee894e","signature":false,"impliedFormat":1},{"version":"fb2ea35e1be6388d722d7725e2b49c697d34d9c890c3b96758faaeb86d35cef8","signature":false,"impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","signature":false,"impliedFormat":1},{"version":"1a4dc28334a926d90ba6a2d811ba0ff6c22775fcc13679521f034c124269fd40","signature":false,"impliedFormat":1},{"version":"f05315ff85714f0b87cc0b54bcd3dde2716e5a6b99aedcc19cad02bf2403e08c","signature":false,"impliedFormat":1},{"version":"5fad3b31fc17a5bc58095118a8b160f5260964787c52e7eb51e3d4fcf5d4a6f0","signature":false,"impliedFormat":1},{"version":"72105519d0390262cf0abe84cf41c926ade0ff475d35eb21307b2f94de985778","signature":false,"impliedFormat":1},{"version":"456006a6975b26c0a1785feddae165f6d307e2d601ffde27e21fc4a790e448a4","signature":false,"impliedFormat":1},{"version":"c857e0aae3f5f444abd791ec81206020fbcc1223e187316677e026d1c1d6fe08","signature":false,"impliedFormat":1},{"version":"ccf6dd45b708fb74ba9ed0f2478d4eb9195c9dfef0ff83a6092fa3cf2ff53b4f","signature":false,"impliedFormat":1},{"version":"1fe0d18b111e1145a7e7601855bccd4ca20f24e3b9a5aba6bb1fa9d1a7059170","signature":false,"impliedFormat":1},{"version":"5632c3c26d420c063eebe64c45b1248b9492a67bf44f1d0c57e9dc8f6cf449bb","signature":false,"impliedFormat":1},{"version":"2a69b0a1f375fa5c81f58b99d6d6722b19a5ec1b12d8b3836f3cf46a3fc47194","signature":false,"impliedFormat":1},{"version":"8fca3039857709484e5893c05c1f9126ab7451fa6c29e19bb8c2411a2e937345","signature":false,"impliedFormat":1},{"version":"35069c2c417bd7443ae7c7cafd1de02f665bf015479fec998985ffbbf500628c","signature":false,"impliedFormat":1},{"version":"10ab7be91f87ebe8916b62cf28af2e45b5601fc7b0e311adf838f912c6b31dd8","signature":false,"impliedFormat":1},{"version":"bc636fbc08e0979ceb7eb0731a33000283d77a33b62e1f71ee65be50394e40ba","signature":false,"impliedFormat":1},{"version":"7e0b7f91c5ab6e33f511efc640d36e6f933510b11be24f98836a20a2dc914c2d","signature":false,"impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","signature":false,"impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","signature":false,"impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","signature":false,"impliedFormat":1},{"version":"75bbd3be047d539988a0ff0b56384ef7a6a25f3b676ad96bee547d44c31622a7","signature":false,"impliedFormat":1},{"version":"42960001a776b089ade681ab5cfddc936e0afb0615133ec1841f3dee89d3e1bf","signature":false,"impliedFormat":1},{"version":"0aedb02516baf3e66b2c1db9fef50666d6ed257edac0f866ea32f1aa05aa474f","signature":false,"impliedFormat":1},{"version":"da47712b394d944328245482603bc6f416d3949b67c9392279caab595076b510","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"37d0071d8f0a06dc55c2c5e0ec3391affd4fd107c53410bf358196ec0bf3923f","signature":false,"impliedFormat":1},{"version":"b213dad76ca37fd552274c9499056e1c0d9c1bd38a55bb7f68b22ba6b84c3ad7","signature":false,"impliedFormat":1},{"version":"b68e51f106da141332af1441715fd8c7a872a843c6388c555f3e4338d778d796","signature":false,"impliedFormat":1},{"version":"c30436b130b6218b7714314dc41d3f459590db4bdf099eecd51cb1bda32109a8","signature":false,"impliedFormat":1},{"version":"d90b9f1520366d713a73bd30c5a9eb0040d0fb6076aff370796bc776fd705943","signature":false,"impliedFormat":1},{"version":"5ff4a63bbe43153fa69ca6f138c0ccb89344c891200d080c8cebc0d2db7d0418","signature":false,"impliedFormat":1},{"version":"84580e0939eca6f38d270bed6b28eda3bef0268834b4816a1fe8391b2ad15a42","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"bef86adb77316505c6b471da1d9b8c9e428867c2566270e8894d4d773a1c4dc2","signature":false,"impliedFormat":1},{"version":"5a49adaef698b7ad7e6127949fa1b0bbd3d46b7cbd11c54e392a4dcdd51f5190","signature":false,"impliedFormat":1},{"version":"6ee598cdfdd0fa52039dca135b3dfff7b49035dc13292143e0a93843e3861967","signature":false,"impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","signature":false,"impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","signature":false,"impliedFormat":1},{"version":"2489bf04d77dc025ba67f49f1a56eb24b9db477d5ff88123d887e163ed1776aa","signature":false,"impliedFormat":1},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","signature":false,"impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","signature":false,"impliedFormat":1},{"version":"0b77b819b5417775fccb20c678293cf614c054a5b1a65421a5b933a9124ba998","signature":false,"impliedFormat":1},{"version":"eb5acb58487367e502d994b57e2c58255d8241f481ea8efa8e79af23af3f41c2","signature":false,"impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","signature":false,"impliedFormat":1},{"version":"b1f1d57fde8247599731b24a733395c880a6561ec0c882efaaf20d7df968c5af","signature":false,"impliedFormat":1},{"version":"f1f4095f04343ad6e6825eba41eb50f1555685560dde164179a467e65c4a921c","signature":false,"impliedFormat":1},{"version":"89d5d28d4f57e000b836ac273079be1b75710e28ce14750d081fb420d37e2ca5","signature":false,"impliedFormat":1},{"version":"af48e58339188d5737b608d41411a9c054685413d8ae88b8c1d0d9bfabdf6e7e","signature":false,"impliedFormat":1},{"version":"5a3ea721d03a361ccbdd7390ccd75f6e84cbca3a3f01f4b331ecc9af31890c49","signature":false,"impliedFormat":1},{"version":"a1edf913cd024b0793c80fd7a669191fa41d312ccfde9db799ddae11d68e507f","signature":false,"affectsGlobalScope":true,"impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","signature":false,"impliedFormat":1},{"version":"d309db88aa0aff9302813d8275ec7e336bda278a08788ccf79e6a153881ce7ad","signature":false,"impliedFormat":1},{"version":"ff863d17c6c659440f7c5c536e4db7762d8c2565547b2608f36b798a743606ca","signature":false,"impliedFormat":1},{"version":"5412ad0043cd60d1f1406fc12cb4fb987e9a734decbdd4db6f6acf71791e36fe","signature":false,"impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","signature":false,"impliedFormat":1},{"version":"e0b780c4e549f723de7e1936235e6a58bd31b4403737f158d1161ddbaafa93d2","signature":false,"impliedFormat":1},{"version":"3c0d1ad6cf575b9b59441c791eaadeb3425c94d4ad27f9b4f60ea0bf8e4ca6dd","signature":false,"affectsGlobalScope":true},{"version":"7b550dda9686c16f36a17bf9051d5dbf31e98555b30d114ac49fc49a1e712651","signature":false},{"version":"06056b049d4e61086019c0c0538c05e431c92ab5acdb249bb3e67676face01ef","signature":false},{"version":"1f3324089e2a6274549bb8e4d2838372dac258f0ef34d22e584144dbf383dd51","signature":false,"impliedFormat":1},{"version":"d6a0f4d97ba0a599d066fbab8c79b6ce80f82226d69dc192df0d11b2d42fc123","signature":false,"impliedFormat":1},{"version":"6db43bc50f52dc050909d04955fe213e80be41e0d6507c9eca43794afa946bd6","signature":false,"impliedFormat":1},{"version":"8ed0848d2cc5673b5d28f635d3bb987960f4ecbd2e32348c19a021671f914955","signature":false,"impliedFormat":1},{"version":"1e338ea2bf68a11b849540ceaf9b011efa42eb9bd81120c06835c26e5a573212","signature":false,"impliedFormat":1},{"version":"dbeff3855e16084e50db8ba7b4c28d9bcc81d9849445b1ac1a4bdb287c1c1066","signature":false,"impliedFormat":1},{"version":"0bbd752fdb21b960804c00b17ad4705fb40e26ef91d3bfe4da893c3d62e88a0b","signature":false,"impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","signature":false,"impliedFormat":1},{"version":"26bbaf075b175d3b03a29948ef0ad1934770a526ea51e2a194233e031a60b6b7","signature":false,"impliedFormat":1},{"version":"65c4aa5a37a32fd6328093077104bea76928aab483e4b7b098f547b8d436629b","signature":false,"impliedFormat":1},{"version":"9b1f56f50f19767783936165a45f65add0cec1437e0436ca72803455ed89acd5","signature":false,"impliedFormat":1},{"version":"b51a24f1ca9f08aff253bd36c8bf26432add229c95e3b9e68d5d7e3902397fc6","signature":false,"impliedFormat":1},{"version":"d13ffdc07926f7e509f4b4f294edda43d817f75ad72e82eafe6de19cf27f3b01","signature":false,"impliedFormat":1},{"version":"18ee0b53fb70d553f44e967630a71107c6564a55e4dd9489fa3e79006c87e34f","signature":false,"impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","signature":false,"impliedFormat":1},{"version":"2a787887142e04e8df36a96a39687cd7a677cef5db4fcfb2e46f3848575ac2be","signature":false,"impliedFormat":1},{"version":"2ff07f0b2fd330fc5f2ad1486cc6429ed9dc4178ae1dd64ccb1c4693f8c9964e","signature":false,"impliedFormat":1},{"version":"356542e9684284b04199ca3858ffb8494dae1c7be4160fed24a9eeda0127c97f","signature":false,"impliedFormat":1},{"version":"d78de800b248c6b887de2f7d05503e97a5f3cc8bcd219e6134410418533103a5","signature":false,"impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","signature":false,"impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","signature":false,"impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","signature":false,"impliedFormat":1},{"version":"1a2c241291456346f21c040c397aabb642ba3a8803b0824f93254498f4209136","signature":false,"impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","signature":false,"impliedFormat":1},{"version":"0db0fb120ce9cbcaa9606631141348a5b995d1c7fe359f16bcb618dac13223b9","signature":false},{"version":"c57b441e0c0a9cbdfa7d850dae1f8a387d6f81cbffbc3cd0465d530084c2417d","signature":false,"impliedFormat":99},{"version":"6cb7ffe77af62152fd2d607d4772c15b6c91596ac89918a35580daa681cc29fd","signature":false},{"version":"8c71045ceadddc56ecdae8427c0dc03bbd19a10e034fac330675906aece2e841","signature":false},{"version":"822f8f43960dd8b6bf024f4e32ca192d5e0c01e80b308acc34e1255bd644d012","signature":false},{"version":"913933f803186c7e0f998a17f392687200e87f3faff61a1a524503a78a4cff2f","signature":false},{"version":"28fd46790503759c1fe0764bacb9f5fbd8470f54347696c581000459e0f8c272","signature":false},{"version":"01d77ff11f1b8f6d4bed553cb67aa414b10e9fe71e593bb567cc202e29bb0aef","signature":false},{"version":"52c0b2ca62200e05b413e8238d452b51897e20ef37e6682c50f0a8f72cd4e1ad","signature":false},{"version":"38570c88b7531c1bf227334ff8ece6dbc3bffd14714ef705afc4157cbc5d44f4","signature":false},{"version":"73c078fcbc0fa04ba70b1c3e5a3dea6a980d8765079cdbfb40f903eb8daa4319","signature":false,"impliedFormat":99},{"version":"5297e84d3de08bbe3c00f964d1c74f89cf101d59a4826b335654f44ff41529a8","signature":false,"impliedFormat":99},{"version":"355b33af59287683501f76cbf7d6a141544c5ff1ae5f5c0701a3f89cc38e5238","signature":false,"impliedFormat":99},{"version":"280a996092ab956e80dc7bb7497d472ca5c1be23a9c52ac771f5c750ede462b9","signature":false,"impliedFormat":99},{"version":"3b57d4625b2320c94f210a74a7335236696d83fb00849fdbbc5d3727461b7924","signature":false},{"version":"1494a149921fd88b2b9617eb95b07b36fbf30a1b9c20e3d67673a128bed2a9ee","signature":false},{"version":"135c7d14b681337e28ba567f1a66bd5e03dce2ed8db58e7b2574c1fdb040fc15","signature":false},{"version":"faf2b9bbb42b8cecfd96c6e21478b4b9bf7a2df7e938704282c0b197563f42a0","signature":false},{"version":"e297c0a524edee7677939122f90027bfbe5f2698939d9a85728e5044b39c7124","signature":false,"impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","signature":false,"impliedFormat":1},{"version":"7d459c17bb5444f64cc0fb0ad57f796d4362a8e639d0edda6cdb574d9d798753","signature":false,"impliedFormat":1},{"version":"b62381cae176db34f003cc6172ee8f3e0122014889d66391aa73698105cf4934","signature":false,"impliedFormat":1},{"version":"1d9c0a9a6df4e8f29dc84c25c5aa0bb1da5456ebede7a03e03df08bb8b27bae6","signature":false,"impliedFormat":1},{"version":"84380af21da938a567c65ef95aefb5354f676368ee1a1cbb4cae81604a4c7d17","signature":false,"impliedFormat":1},{"version":"1af3e1f2a5d1332e136f8b0b95c0e6c0a02aaabd5092b36b64f3042a03debf28","signature":false,"impliedFormat":1},{"version":"30d8da250766efa99490fc02801047c2c6d72dd0da1bba6581c7e80d1d8842a4","signature":false,"impliedFormat":1},{"version":"03566202f5553bd2d9de22dfab0c61aa163cabb64f0223c08431fb3fc8f70280","signature":false,"impliedFormat":1},{"version":"41eb514d9ce0a6e87957f08a4b7af70d93f87637f37dee706e2d92a6601c25a9","signature":false,"impliedFormat":1},{"version":"6e6ec937dbcfa890ba36c218cadb4596dd39517e5686737cd53da09449867369","signature":false,"impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","signature":false,"impliedFormat":1},{"version":"83fd65fee2246e130a91b1b50076833e72dafd4483e7dd184292adeb542ea46c","signature":false},{"version":"d43ba720d037d96722a10d0f1ba5ee9e6c84b3af95687f28d0550412bd614f3c","signature":false},{"version":"a09b483cdb0471aa79ac4661fc613263e447469bcac324192b6e05d370b7fd89","signature":false},{"version":"6d696c23cd7da1487b756388c2b167b7c644330ac81033b35405f2399518df26","signature":false},{"version":"ac62517c0fcf3bc3e98740a9330955807b5682f1ad323c043a7a3936b1a661fe","signature":false},{"version":"48f9798f59636835f51c3f9d1fa0a871b91f2d63a2b4e7bd7fa321cdb5b2c606","signature":false},{"version":"cf0b18e4c4acbd0876ff70d84b8d038e431d3d428fb58676ea6199640b6e8f1f","signature":false},{"version":"94e089243fde8466e7d81f98b2ee7e961dca8937e813b0d3f3a5165e0d41252a","signature":false},{"version":"222e2538612b622a35722b916be75760ad4a02c925314c1063abc3bc390f1d51","signature":false},{"version":"12f5a94716c7d6ccb40c551d38102792e28491deb3dfba3a79d18bd17d96ad70","signature":false},{"version":"b391194e7b9d252eaa4a2d9319e1f49995d7793823711907584c237ce32626e2","signature":false},{"version":"996281aca4a8f05a886c64911ea16d5ce50081bc16b9f2e75cdfa70a8fd95222","signature":false},{"version":"ab81041ab5fef4781b17b5800394c430df590f091d34c4722c97c45b1100484b","signature":false},{"version":"9c6707398b0607874f607655f715c16ce4303c9789e14a372bdbd0de3d7b8a0c","signature":false},{"version":"d1f926ae6d4e7402d3a9f7ed725b439f7abeb58c567e39c0d3875ca832632549","signature":false},{"version":"ae91a6482bec5c3a0d5ec73da0cf8773c437360d7a9504aa49c6fedd89ae59ed","signature":false},{"version":"68293858dbd621ca15013a858281408cb8ea1b6c7dd91d4170de6e3e1482346b","signature":false},{"version":"246f0ac2fdc36cbe14b04028b58e5a7f9ec842ec1a8c42b22944404d7877f659","signature":false},{"version":"cfe7f8bae9581d9e1f769c22b7f0c2c2e987a7d56b79de5c68b47e59ef8eceb2","signature":false},{"version":"a98870280b5912ff2b3d2a7852b986c5781c82adfb6f66f30c2bbb67a673820f","signature":false},{"version":"4ddcb90a49775464b3eacaf99e89003a69be97b5691e49807a0f4af2eea74a92","signature":false},{"version":"d115bda99a7c1efee6b718b042f4a56dc7bbdf187e1df076912e9592627fea7a","signature":false},{"version":"2943307d4adb403fd809e4e2da52699d42b7413ca5f4fb3e4ee217b001bef9d4","signature":false},{"version":"02887687deaec2b4645dd1b2d747e632cde288b6f14548df91675d60a1c3a1df","signature":false},{"version":"97ff328603a671b133b0c9f601d1d3d5bffce1e544f850a83bec67ac481ab1b7","signature":false},{"version":"f58d85255adf27af18ffe6f122e781658c0315d18ad8e747e854a5467a289dc2","signature":false},{"version":"c20bc9f5a84e19f62a550af9c02e5a4b63a8219ab5dad16b656afd91fecc23c1","signature":false},{"version":"7f6780d15b4684c743f23d24d0d6f0979825c9aaf24366931f908e8d1521df40","signature":false},{"version":"81ee1614b3b2989901e69823220f2d7af442a200d3e6a2372c5206de99457ccd","signature":false},{"version":"6897813c4a084ad226b4944a9f33ef6080b1dc4a7c39cc45143b2fed34918a14","signature":false},{"version":"170e096c26e235b153c10c9f72bc2a3ba48e00040914511a44bceb0f9cfc9d97","signature":false},{"version":"eb94ce133e54754959598844146441a30bea5ad644a6b64ec0eca351e03d2c2c","signature":false},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","signature":false,"impliedFormat":1},{"version":"1de8c302fd35220d8f29dea378a4ae45199dc8ff83ca9923aca1400f2b28848a","signature":false,"impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","signature":false,"impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","signature":false,"impliedFormat":1},{"version":"d1986184a09a52db8228cb2bb2a61a8c05c9354e5b93cec8e2628d8579c892d7","signature":false},{"version":"e3a27d7905cf691aa031ff685be6bcec6e58c21d8000a4ff9c53b386c550aa30","signature":false},{"version":"986139b6f695c5ac79d23eda66102b1f8fd478ff1143051d32590a2f1a6d5793","signature":false,"impliedFormat":1},{"version":"3532ede31c1cc6380dc3f384a8073e121a7a6e545dd0997fcc03ba155f547cf9","signature":false,"impliedFormat":1},{"version":"70eb9b06c735a3814875ae0a7c56c531886ad5631eb087663282f47ce57c2331","signature":false,"impliedFormat":1},{"version":"c9f1aba446cacc2385528b88affd9ec93aa68a325290fececdc776bef97c97fb","signature":false,"affectsGlobalScope":true,"impliedFormat":1}],"root":[[297,299],324,[326,333],[338,341],[354,385],390,391],"options":{"allowJs":true,"esModuleInterop":true,"jsx":4,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[390,1],[391,2],[358,3],[360,4],[359,5],[356,6],[364,7],[362,4],[365,7],[361,8],[357,9],[367,4],[366,5],[370,10],[369,4],[371,11],[368,8],[373,5],[374,12],[372,13],[376,14],[380,15],[382,16],[381,17],[377,4],[340,18],[384,19],[385,11],[341,20],[354,21],[363,5],[383,22],[339,23],[355,24],[327,25],[326,26],[328,26],[331,27],[330,22],[329,26],[375,11],[378,16],[379,11],[332,21],[333,21],[338,28],[298,29],[299,30],[335,31],[336,32],[337,33],[272,34],[269,34],[273,35],[10,36],[270,34],[39,34],[289,37],[53,38],[191,39],[194,40],[179,41],[186,42],[165,43],[211,44],[42,45],[193,46],[195,47],[196,48],[267,49],[160,50],[113,51],[173,52],[174,53],[172,54],[167,55],[192,56],[54,57],[238,58],[80,59],[55,60],[6,59],[116,59],[17,59],[189,61],[178,62],[245,63],[246,63],[242,64],[247,22],[243,65],[352,66],[351,67],[146,68],[345,69],[244,34],[99,70],[106,71],[108,72],[103,73],[105,74],[107,75],[102,76],[104,77],[350,78],[97,79],[294,80],[342,34],[87,81],[86,82],[85,83],[84,84],[175,79],[176,85],[177,86],[35,87],[259,34],[15,88],[258,89],[257,90],[254,91],[252,92],[255,93],[253,92],[205,94],[206,95],[204,96],[202,97],[203,98],[265,22],[40,22],[134,99],[280,100],[18,101],[161,102],[162,103],[240,104],[263,105],[138,34],[155,106],[266,107],[154,108],[264,109],[261,110],[236,111],[23,112],[136,113],[140,114],[156,115],[159,116],[148,117],[141,118],[19,119],[214,120],[132,121],[286,122],[287,123],[207,124],[208,125],[225,126],[224,127],[219,128],[239,129],[223,130],[73,131],[157,132],[78,133],[227,134],[228,135],[230,136],[232,137],[231,138],[234,139],[131,140],[220,141],[226,142],[69,143],[123,144],[127,145],[124,146],[126,147],[129,145],[125,146],[36,148],[115,149],[283,150],[276,151],[24,152],[21,152],[38,153],[37,154],[33,155],[34,156],[43,157],[72,157],[81,157],[117,158],[82,158],[26,159],[121,160],[120,161],[119,162],[118,163],[27,164],[268,165],[71,166],[275,167],[241,168],[271,169],[274,170],[164,171],[163,172],[144,173],[130,174],[112,175],[114,176],[111,177],[233,178],[32,179],[235,180],[282,181],[74,182],[147,183],[76,184],[209,185],[77,186],[210,186],[212,187],[109,188],[30,189],[14,190],[293,191],[96,34],[95,192],[11,193],[93,191],[295,194],[91,34],[92,34],[90,195],[89,196],[79,197],[217,198],[101,79],[110,34],[285,87],[288,199],[185,200],[183,201],[290,202],[296,203],[292,204],[343,205],[353,206],[291,87],[316,207],[314,208],[315,209],[303,210],[304,208],[311,211],[302,212],[307,213],[308,214],[313,215],[319,216],[318,217],[301,218],[309,219],[310,220],[305,221],[312,207],[306,222],[322,223],[323,224],[324,225]],"changeFileSet":[390,297,391,358,360,359,356,364,362,365,361,357,367,366,370,369,371,368,373,374,372,376,380,382,381,377,340,384,385,341,354,363,383,339,355,327,326,328,331,330,329,375,378,379,332,333,338,298,299,187,334,335,336,337,392,393,394,395,9,272,269,273,10,270,39,325,8,289,44,53,191,194,166,179,186,70,168,51,165,211,52,42,193,195,196,267,160,113,173,174,172,171,167,192,54,237,238,80,55,6,116,17,189,188,178,284,28,245,246,242,347,143,247,243,352,351,346,94,146,145,345,244,99,106,108,98,103,105,107,102,100,104,348,344,350,349,97,294,342,87,86,85,84,75,5,175,176,177,48,180,35,41,259,15,258,257,248,249,256,251,254,250,252,255,253,50,46,47,200,205,206,204,202,203,198,265,40,134,133,128,280,18,161,162,240,150,263,138,155,266,151,154,152,264,261,260,262,158,236,23,136,140,156,159,148,141,19,214,132,20,286,287,207,199,208,225,197,224,4,219,45,239,215,29,31,170,223,49,73,157,78,137,222,201,227,228,169,230,232,231,181,221,234,131,220,226,58,62,61,60,65,59,68,67,64,63,66,69,57,123,122,127,124,126,129,125,36,115,283,281,276,24,21,56,38,37,33,34,43,72,81,117,82,26,25,121,120,119,118,27,268,71,275,241,271,274,164,163,144,130,112,114,111,233,135,389,32,235,282,142,74,149,147,76,209,277,77,210,387,386,388,279,278,212,139,109,30,88,14,7,13,293,96,95,11,93,16,295,91,92,83,12,90,89,79,153,213,229,217,216,101,22,110,285,288,190,185,184,183,182,290,296,292,343,353,291,316,314,315,303,304,311,302,307,317,308,313,319,318,301,309,310,305,312,306,218,300,322,321,320,323,2,3,1,324],"version":"5.9.3"} \ No newline at end of file +{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/lib/fallback.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/entry-constants.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/lib/bundler.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/request/search-params.d.ts","./node_modules/next/dist/shared/lib/segment-cache/vary-params-decoding.d.ts","./node_modules/next/dist/server/app-render/vary-params.d.ts","./node_modules/next/dist/server/request/params.d.ts","./node_modules/next/dist/server/route-kind.d.ts","./node_modules/next/dist/server/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/route-matches/route-match.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/server/lib/cache-control.d.ts","./node_modules/next/dist/shared/lib/app-router-types.d.ts","./node_modules/next/dist/server/lib/cache-handlers/types.d.ts","./node_modules/next/dist/server/use-cache/use-cache-wrapper.d.ts","./node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","./node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/next-devtools/userspace/pages/pages-dev-overlay-setup.d.ts","./node_modules/next/dist/build/static-paths/types.d.ts","./node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/build/adapter/setup-node-env.external.d.ts","./node_modules/next/dist/server/instrumentation/types.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/worker.d.ts","./node_modules/next/dist/server/lib/experimental/ppr.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","./node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/server/node-environment-baseline.d.ts","./node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","./node_modules/next/dist/server/node-environment-extensions/console-file.d.ts","./node_modules/next/dist/server/node-environment-extensions/console-exit.d.ts","./node_modules/next/dist/server/node-environment-extensions/console-dim.external.d.ts","./node_modules/next/dist/server/node-environment-extensions/unhandled-rejection.external.d.ts","./node_modules/next/dist/server/node-environment-extensions/random.d.ts","./node_modules/next/dist/server/node-environment-extensions/date.d.ts","./node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/fast-set-immediate.external.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","./node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","./node_modules/next/dist/server/lib/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/client/components/readonly-url-search-params.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/client/flight-data-helpers.d.ts","./node_modules/next/dist/client/components/segment-cache/cache-key.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/client/components/segment-cache/types.d.ts","./node_modules/next/dist/shared/lib/segment-cache/segment-value-encoding.d.ts","./node_modules/next/dist/client/components/segment-cache/scheduler.d.ts","./node_modules/next/dist/client/components/segment-cache/cache-map.d.ts","./node_modules/next/dist/client/components/segment-cache/vary-path.d.ts","./node_modules/next/dist/client/components/segment-cache/cache.d.ts","./node_modules/next/dist/client/components/router-reducer/ppr-navigations.d.ts","./node_modules/next/dist/client/components/segment-cache/navigation.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/normalizers/normalizer.d.ts","./node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/after/builtin-request-context.d.ts","./node_modules/next/dist/server/normalizers/request/segment-prefix-rsc.d.ts","./node_modules/next/dist/server/route-modules/pages/builtin/_error.d.ts","./node_modules/next/dist/server/load-default-error-components.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/after/after.d.ts","./node_modules/next/dist/server/after/after-context.d.ts","./node_modules/next/dist/server/use-cache/cache-life.d.ts","./node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","./node_modules/next/dist/server/lib/lazy-result.d.ts","./node_modules/next/dist/server/app-render/create-error-handler.d.ts","./node_modules/next/dist/shared/lib/action-revalidation-kind.d.ts","./node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","./node_modules/next/dist/server/async-storage/work-store.d.ts","./node_modules/next/dist/server/web/http.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect-error.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/server/app-render/cache-signal.d.ts","./node_modules/next/dist/server/app-render/instant-validation/boundary-tracking.d.ts","./node_modules/next/dist/server/app-render/instant-validation/instant-validation-error.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","./node_modules/next/dist/server/app-render/instant-validation/instant-samples.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","./node_modules/next/dist/server/lib/implicit-tags.d.ts","./node_modules/next/dist/server/app-render/staged-rendering.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","./node_modules/next/dist/build/templates/app-route.d.ts","./node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","./node_modules/next/dist/build/segment-config/app/app-segments.d.ts","./node_modules/next/dist/build/get-supported-browsers.d.ts","./node_modules/next/dist/build/utils.d.ts","./node_modules/next/dist/build/rendering-mode.d.ts","./node_modules/next/dist/server/lib/router-utils/build-prefetch-segment-data-route.d.ts","./node_modules/next/dist/server/lib/cpu-profile.d.ts","./node_modules/next/dist/build/turborepo-access-trace/types.d.ts","./node_modules/next/dist/build/turborepo-access-trace/result.d.ts","./node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","./node_modules/next/dist/build/turborepo-access-trace/index.d.ts","./node_modules/next/dist/export/routes/types.d.ts","./node_modules/next/dist/export/types.d.ts","./node_modules/next/dist/export/worker.d.ts","./node_modules/next/dist/build/worker.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/use-cache-tracker-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/telemetry-plugin.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/build/build-context.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/swc/generated-native.d.ts","./node_modules/next/dist/build/define-env.d.ts","./node_modules/next/dist/build/swc/index.d.ts","./node_modules/next/dist/build/swc/types.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/next-devtools/shared/types.d.ts","./node_modules/next/dist/server/dev/dev-indicator-server-state.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/cache-indicator.d.ts","./node_modules/next/dist/server/lib/parse-stack.d.ts","./node_modules/next/dist/next-devtools/server/shared.d.ts","./node_modules/next/dist/next-devtools/shared/stack-frame.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/utils/get-error-by-type.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/container/runtime-error/render-error.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/shared.d.ts","./node_modules/next/dist/server/dev/debug-channel.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/server/lib/async-callback-set.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/sharp/lib/index.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/lru-cache.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/router-utils/router-server-context.d.ts","./node_modules/next/dist/server/route-modules/route-module.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/server/web/adapter.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/client-segment.d.ts","./node_modules/next/dist/client/components/http-access-fallback/error-boundary.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/dist/lib/metadata/types/resolvers.d.ts","./node_modules/next/dist/lib/metadata/types/icons.d.ts","./node_modules/next/dist/lib/metadata/resolve-metadata.d.ts","./node_modules/next/dist/lib/metadata/metadata.d.ts","./node_modules/next/dist/lib/framework/boundary-components.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/collect-segment-data.d.ts","./node_modules/next/dist/server/app-render/instant-validation/instant-validation.d.ts","./node_modules/next/dist/next-devtools/userspace/app/segment-explorer-node.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/route-modules/app-page/helpers/prerender-manifest-matcher.d.ts","./node_modules/@types/react/jsx-dev-runtime.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/rsc/entrypoints.d.ts","./node_modules/@types/react-dom/client.d.ts","./node_modules/@types/react-dom/server.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/ssr/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/request/fallback-params.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/server/web/spec-extension/url-pattern.d.ts","./node_modules/next/dist/server/after/index.d.ts","./node_modules/next/dist/server/request/connection.d.ts","./node_modules/next/dist/server/web/exports/index.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/cli/next-test.d.ts","./node_modules/next/dist/shared/lib/size-limit.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/build/adapter/build-complete.d.ts","./node_modules/next/dist/types.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/dist/server/use-cache/cache-tag.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/dist/client/components/catch-error.d.ts","./node_modules/next/dist/api/error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/server/request/cookies.d.ts","./node_modules/next/dist/server/request/headers.d.ts","./node_modules/next/dist/server/request/draft-mode.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/unrecognized-action-error.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/forbidden.d.ts","./node_modules/next/dist/client/components/unauthorized.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.server.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/types.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./.next/types/routes.d.ts","./next-env.d.ts","./next.config.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/tailwindcss/types/generated/corepluginlist.d.ts","./node_modules/tailwindcss/types/generated/colors.d.ts","./node_modules/tailwindcss/types/config.d.ts","./node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","./node_modules/clsx/clsx.d.mts","./components/ui/button.tsx","./components/ui/badge.tsx","./components/ui/card.tsx","./components/ui/table.tsx","./components/ui/modal.tsx","./components/ui/index.ts","./lib/api.ts","./lib/guacconsole.ts","./node_modules/@tanstack/query-core/build/modern/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/query-core/build/modern/index.d.ts","./node_modules/@tanstack/react-query/build/modern/_tsup-dts-rollup.d.ts","./node_modules/@tanstack/react-query/build/modern/index.d.ts","./lib/query-client.ts","./components/providers.tsx","./app/layout.tsx","./components/authprovider.tsx","./components/logo.tsx","./components/sidebar.tsx","./app/(app)/layout.tsx","./app/(app)/page.tsx","./app/(app)/audit/page.tsx","./app/(app)/keys/page.tsx","./app/(app)/keys/[id]/page.tsx","./app/(app)/monitors/page.tsx","./app/(app)/monitors/[id]/page.tsx","./components/monitors/monitorform.tsx","./app/(app)/monitors/[id]/edit/page.tsx","./app/(app)/monitors/new/page.tsx","./app/(app)/secrets/page.tsx","./app/(app)/secrets/[group]/page.tsx","./app/(app)/servers/page.tsx","./app/(app)/servers/[id]/page.tsx","./app/(app)/servers/[id]/console/page.tsx","./app/(app)/servers/new/page.tsx","./app/(app)/settings/page.tsx","./app/(app)/settings/instance/page.tsx","./app/(app)/settings/notifications/page.tsx","./components/workflows/editstepmodal.tsx","./app/(app)/steps/page.tsx","./app/(app)/workflows/page.tsx","./components/workflows/editworkflowmodal.tsx","./components/workflows/steppickermodal.tsx","./app/(app)/workflows/[id]/page.tsx","./app/(app)/workflows/[id]/runs/page.tsx","./app/(app)/workflows/[id]/runs/[runid]/page.tsx","./components/networkbackground.tsx","./app/login/page.tsx","./app/setup/page.tsx","./.next/types/cache-life.d.ts","./.next/types/validator.ts","./node_modules/@types/estree/index.d.ts","./node_modules/@types/json-schema/index.d.ts","./node_modules/@types/json5/index.d.ts"],"fileIdsList":[[98,144,482,483,484,485],[98,144],[98,144,227,526,529,572,576,577,578,579,580,581,582,584,585,586,587,588,589,590,591,592,593,594,596,597,600,601,602,604,605],[98,144,227,563,564,569],[86,98,144,227,506,516,563,564,569],[86,98,144,227,506,563,564,569],[98,144,227,573,575],[98,144,227,506,516,563,564,569,583],[98,144,227,506,563,564,569],[98,144,227,516],[86,98,144,227,506,516,563,564,565,569],[86,98,144,227,563,564,569],[86,98,144,227,563,564,569,573],[86,98,144,227,506,563,564,569,573],[86,98,144,227,563,564,569,595],[86,98,144,227,506,516,563,564,569,598,599],[86,98,144,227,516,563,564,569],[98,144,227,506,516,563,564,569],[98,144,227,524,527,571],[86,98,144,227,563,564,569,574,603],[86,98,144,227,564],[98,144,227],[86,98,144,227],[98,144,227,569,570],[98,144,227,506,516,557,564,573,574],[98,144,227,557],[86,98,144,227,557],[98,144,227,558,559,560,561,562],[98,144,227,569],[98,144,527,528,529],[98,144,227,527],[98,144,566],[86,98,144,227,567],[98,144,568],[98,141,144],[98,143,144],[144],[98,144,149,177],[98,144,145,150,155,163,174,185],[98,144,145,146,155,163],[93,94,95,98,144],[98,144,147,186],[98,144,148,149,156,164],[98,144,149,174,182],[98,144,150,152,155,163],[98,143,144,151],[98,144,152,153],[98,144,154,155],[98,143,144,155],[98,144,155,156,157,174,185],[98,144,155,156,157,170,174,177],[98,144,152,155,158,163,174,185],[98,144,155,156,158,159,163,174,182,185],[98,144,158,160,174,182,185],[96,97,98,99,100,101,102,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191],[98,144,155,161],[98,144,162,185,190],[98,144,152,155,163,174],[98,144,164],[98,144,165],[98,143,144,166],[98,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191],[98,144,168],[98,144,169],[98,144,155,170,171],[98,144,170,172,186,188],[98,144,155,174,175,177],[98,144,176,177],[98,144,174,175],[98,144,177],[98,144,178],[98,141,144,174,179],[98,144,155,180,181],[98,144,180,181],[98,144,149,163,174,182],[98,144,183],[98,144,163,184],[98,144,158,169,185],[98,144,149,186],[98,144,174,187],[98,144,162,188],[98,144,189],[98,139,144],[98,139,144,155,157,166,174,177,185,188,190],[98,144,174,191],[86,98,144,195,196,197,459],[86,98,144],[86,98,144,195,196],[86,98,144,196,459],[86,90,98,144,194,477,522],[86,90,98,144,193,477,522],[83,84,85,98,144],[98,144,480],[98,144,430,491,492],[98,144,202,203,205,217,241,356,367,473],[98,144,205,236,237,238,240,473],[98,144,205,373,375,377,378,380,473,475],[98,144,205,239,276,473],[98,144,203,205,216,217,223,229,234,355,356,357,366,473,475],[98,144,473],[98,144,212,218,237,257,352],[98,144,205],[98,144,198,212,218],[98,144,384],[98,144,381,382,384],[98,144,381,383,473],[98,144,158,257,454,470],[98,144,158,328,331,347,352,470],[98,144,158,300,470],[98,144,360],[98,144,359,360,361],[98,144,359],[92,98,144,158,198,205,217,223,229,235,237,241,242,255,256,323,353,354,367,473,477],[98,144,202,205,239,276,373,374,379,473,525],[98,144,239,525],[98,144,202,256,425,473,525],[98,144,525],[98,144,205,239,240,525],[98,144,376,525],[98,144,242,355,358,365],[86,98,144,430],[98,144,169,212,227],[98,144,212,227],[86,98,144,297],[86,98,144,218,227,430],[98,144,212,283,297,298,507,514],[98,144,282,508,509,510,511,513],[98,144,333],[98,144,333,334],[98,144,216,218,285,286],[98,144,218,292,293],[98,144,218,287,295],[98,144,292],[98,144,210,218,285,286,287,288,289,290,291,292,295],[98,144,218,285,292,293,294,296],[98,144,218,286,288,289],[98,144,286,288,291,293],[98,144,512],[98,144,218],[86,98,144,206,501],[86,98,144,185],[86,98,144,239,274],[86,98,144,239,367],[98,144,272,277],[86,98,144,273,479],[86,90,98,144,158,193,194,477,521],[98,144,158,218],[98,144,158,217,222,303,320,362,363,367,422,424,473,474],[98,144,255,364],[98,144,477],[98,144,204],[86,98,144,209,212,427,443,445],[98,144,169,212,427,442,443,444,524],[98,144,436,437,438,439,440,441],[98,144,438],[98,144,442],[98,144,227,391,392,394],[86,98,144,218,385,386,387,388,393],[98,144,391,393],[98,144,389],[98,144,390],[86,98,144,227,273,479],[86,98,144,227,478,479],[86,98,144,227,479],[98,144,320,321],[98,144,321],[98,144,158,474,479],[98,144,350],[98,143,144,349],[98,144,212,218,224,226,328,341,345,347,424,427,462,463,470,474],[98,144,218,267,289],[98,144,328,339,342,347],[86,98,144,209,212,328,331,347,350,384,431,432,433,434,435,446,447,448,449,450,451,452,453,525],[98,144,209,212,237,328,335,336,337,340,341],[98,144,174,218,237,339,346,427,428,470],[98,144,343],[98,144,158,169,206,218,222,232,264,265,268,320,323,388,422,423,462,473,474,475,477,525],[98,144,209,210,212],[98,144,328],[98,143,144,237,264,265,322,323,324,325,326,327,474],[98,144,347],[98,143,144,211,212,222,226,262,328,335,336,337,338,339,342,343,344,345,346,463],[98,144,158,262,263,335,474,475],[98,144,237,265,320,323,328,424,474],[98,144,158,473,475],[98,144,158,174,470,474,475],[98,144,158,169,198,212,217,224,226,229,232,239,259,264,265,266,267,268,303,304,306,309,311,314,315,316,317,319,367,422,424,470,473,474,475],[98,144,158,174],[98,144,205,206,207,235,470,471,472,477,479,525],[98,144,202,203,473],[98,144,396],[98,144,158,174,185,214,380,384,385,386,387,388,394,395,525],[98,144,169,185,198,212,214,226,229,265,304,309,319,320,373,400,401,402,408,411,412,422,424,470,473],[98,144,229,235,242,255,265,323,473],[98,144,158,185,206,217,226,265,406,470,473],[98,144,426],[98,144,158,396,409,410,419],[98,144,470,473],[98,144,325,463],[98,144,226,264,367,479],[98,144,158,169,204,309,369,373,402,408,411,414,470],[98,144,158,242,255,373,415],[98,144,205,266,367,417,473,475],[98,144,158,185,388,473],[98,144,158,239,266,367,368,369,378,396,416,418,473],[92,98,144,158,264,421,477,479],[98,144,318,422],[98,144,158,169,212,215,217,218,224,226,232,241,242,255,265,268,304,306,316,319,320,367,400,401,402,403,405,407,422,424,470,479],[98,144,158,174,242,408,413,419,470],[98,144,245,246,247,248,249,250,251,252,253,254],[98,144,259,310],[98,144,312],[98,144,310],[98,144,312,313],[98,144,158,216,217,218,222,223,474],[98,144,158,169,204,206,224,228,264,267,268,302,422,470,475,477,479],[98,144,158,169,185,208,215,216,226,228,265,420,463,469,474],[98,144,335],[98,144,336],[98,144,218,229,462],[98,144,337],[98,144,211],[98,144,213,225],[98,144,158,213,217,224],[98,144,220,225],[98,144,221],[98,144,213,214],[98,144,213,269],[98,144,213],[98,144,215,259,308],[98,144,307],[98,144,212,214,215],[98,144,215,305],[98,144,212,214],[98,144,264,367],[98,144,462],[98,144,158,185,224,226,230,264,367,421,424,427,428,429,455,456,458,461,463,470,474],[98,144,278,281,283,284,297,298],[86,98,144,195,196,197,227,457],[86,98,144,195,196,197,227,457,460],[98,144,351],[98,144,237,258,263,264,328,329,330,331,332,334,347,348,350,353,421,424,473,475],[98,144,297],[98,144,158,302,470],[98,144,302],[98,144,158,224,270,299,301,303,421,470,477,479],[98,144,278,279,280,281,283,284,297,298,478],[92,98,144,158,169,185,213,214,226,232,264,265,268,367,419,420,422,470,473,474,477],[98,144,209,212,219],[98,144,263,265,397,400],[98,144,263,398,464,465,466,467,468],[98,144,158,259,473],[98,144,158],[98,144,262,347],[98,144,261],[98,144,263,316],[98,144,260,262,473],[98,144,158,208,263,397,398,399,470,473,474],[86,98,144,212,218,296],[86,98,144,210],[98,144,200,201],[86,98,144,206],[86,98,144,212,282],[86,92,98,144,264,268,477,479],[98,144,206,501,502],[86,98,144,277],[86,98,144,169,185,204,271,273,275,276,479],[98,144,212,239,474],[98,144,212,404],[86,98,144,156,158,169,202,204,277,375,477,478],[86,98,144,193,194,477,522],[86,87,88,89,90,98,144],[98,144,149],[98,144,370,371,372],[98,144,370],[86,90,98,144,158,160,169,192,193,194,195,197,198,204,232,237,414,442,475,476,479,522],[98,144,487],[98,144,489],[98,144,493],[98,144,495],[98,144,497,498,499],[98,144,503],[91,98,144,481,486,488,490,494,496,500,504,506,516,517,519,523,524,525,526],[98,144,505],[98,144,515],[98,144,273],[98,144,518],[98,143,144,263,397,398,400,464,465,467,468,520,522],[98,144,192],[98,144,547],[98,144,545,547],[98,144,536,544,545,546,548,550],[98,144,534],[98,144,537,542,547,550],[98,144,533,550],[98,144,537,538,541,542,543,550],[98,144,537,538,539,541,542,550],[98,144,534,535,536,537,538,542,543,544,546,547,548,550],[98,144,550],[98,144,532,534,535,536,537,538,539,541,542,543,544,545,546,547,548,549],[98,144,532,550],[98,144,537,539,540,542,543,550],[98,144,541,550],[98,144,542,543,547,550],[98,144,535,545],[98,144,174,192],[98,144,552,553],[98,144,551,554],[98,111,115,144,185],[98,111,144,174,185],[98,106,144],[98,108,111,144,182,185],[98,144,163,182],[98,106,144,192],[98,108,111,144,163,185],[98,103,104,107,110,144,155,174,185],[98,111,118,144],[98,103,109,144],[98,111,132,133,144],[98,107,111,144,177,185,192],[98,132,144,192],[98,105,106,144,192],[98,111,144],[98,105,106,107,108,109,110,111,112,113,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,133,134,135,136,137,138,144],[98,111,126,144],[98,111,118,119,144],[98,109,111,119,120,144],[98,110,144],[98,103,106,111,144],[98,111,115,119,120,144],[98,115,144],[98,109,111,114,144,185],[98,103,108,111,118,144],[98,144,174],[98,106,111,132,144,190,192],[98,144,227,555]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb5b19b86227ace1d29ea4cf81387279d04bb34051e944bc53df69f58914b788","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"09ddcfcfbe77a8232d155ca1030005106b1328f6210df43629d0be750da07c16","affectsGlobalScope":true,"impliedFormat":1},{"version":"acd8fd5090ac73902278889c38336ff3f48af6ba03aa665eb34a75e7ba1dccc4","impliedFormat":1},{"version":"d6258883868fb2680d2ca96bc8b1352cab69874581493e6d52680c5ffecdb6cc","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"f258e3960f324a956fc76a3d3d9e964fff2244ff5859dcc6ce5951e5413ca826","impliedFormat":1},{"version":"643f7232d07bf75e15bd8f658f664d6183a0efaca5eb84b48201c7671a266979","impliedFormat":1},{"version":"21da358700a3893281ce0c517a7a30cbd46be020d9f0c3f2834d0a8ad1f5fc75","impliedFormat":1},{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"ba481bca06f37d3f2c137ce343c7d5937029b2468f8e26111f3c9d9963d6568d","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d9ef24f9a22a88e3e9b3b3d8c40ab1ddb0853f1bfbd5c843c37800138437b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2677634fe27e87348825bb041651e22d50a613e2fdf6a4a3ade971d71bac37e","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"8c0bcd6c6b67b4b503c11e91a1fb91522ed585900eab2ab1f61bba7d7caa9d6f","impliedFormat":1},{"version":"8cd19276b6590b3ebbeeb030ac271871b9ed0afc3074ac88a94ed2449174b776","affectsGlobalScope":true,"impliedFormat":1},{"version":"696eb8d28f5949b87d894b26dc97318ef944c794a9a4e4f62360cd1d1958014b","impliedFormat":1},{"version":"3f8fa3061bd7402970b399300880d55257953ee6d3cd408722cb9ac20126460c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"68bd56c92c2bd7d2339457eb84d63e7de3bd56a69b25f3576e1568d21a162398","affectsGlobalScope":true,"impliedFormat":1},{"version":"3e93b123f7c2944969d291b35fed2af79a6e9e27fdd5faa99748a51c07c02d28","impliedFormat":1},{"version":"9d19808c8c291a9010a6c788e8532a2da70f811adb431c97520803e0ec649991","impliedFormat":1},{"version":"87aad3dd9752067dc875cfaa466fc44246451c0c560b820796bdd528e29bef40","impliedFormat":1},{"version":"4aacb0dd020eeaef65426153686cc639a78ec2885dc72ad220be1d25f1a439df","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"8db0ae9cb14d9955b14c214f34dae1b9ef2baee2fe4ce794a4cd3ac2531e3255","affectsGlobalScope":true,"impliedFormat":1},{"version":"15fc6f7512c86810273af28f224251a5a879e4261b4d4c7e532abfbfc3983134","impliedFormat":1},{"version":"58adba1a8ab2d10b54dc1dced4e41f4e7c9772cbbac40939c0dc8ce2cdb1d442","impliedFormat":1},{"version":"641942a78f9063caa5d6b777c99304b7d1dc7328076038c6d94d8a0b81fc95c1","impliedFormat":1},{"version":"1123a83f35cf56c97de746f0a7250012153c61a167e4a61668bf50e558162d14","impliedFormat":1},{"version":"855cd5f7eb396f5f1ab1bc0f8580339bff77b68a770f84c6b254e319bbfd1ac7","impliedFormat":1},{"version":"5650cf3dace09e7c25d384e3e6b818b938f68f4e8de96f52d9c5a1b3db068e86","impliedFormat":1},{"version":"1354ca5c38bd3fd3836a68e0f7c9f91f172582ba30ab15bb8c075891b91502b7","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e20d899c28ca26a2a7afc98beaa69e63ff7fba0a8bc47b4e3bf3ede5e09e424","impliedFormat":1},{"version":"2d2fcaab481b31a5882065c7951255703ddbe1c0e507af56ea42d79ac3911201","impliedFormat":1},{"version":"a192fe8ec33f75edbc8d8f3ed79f768dfae11ff5735e7fe52bfa69956e46d78d","impliedFormat":1},{"version":"ca867399f7db82df981d6915bcbb2d81131d7d1ef683bc782b59f71dda59bc85","affectsGlobalScope":true,"impliedFormat":1},{"version":"372413016d17d804e1d139418aca0c68e47a83fb6669490857f4b318de8cccb3","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"b4f70ec656a11d570e1a9edce07d118cd58d9760239e2ece99306ee9dfe61d02","impliedFormat":1},{"version":"3bc2f1e2c95c04048212c569ed38e338873f6a8593930cf5a7ef24ffb38fc3b6","impliedFormat":1},{"version":"6e70e9570e98aae2b825b533aa6292b6abd542e8d9f6e9475e88e1d7ba17c866","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"085f552d005479e2e6a7311cdbbe5d8c55c497b4d19274285df161ee9684cd9c","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"007faacc9268357caa21d24169f3f3f2497af3e9241308df2d89f6e6d9bb3f2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74cf591a0f63db318651e0e04cb55f8791385f86e987a67fd4d2eaab8191f730","impliedFormat":1},{"version":"5eab9b3dc9b34f185417342436ec3f106898da5f4801992d8ff38ab3aff346b5","impliedFormat":1},{"version":"12ed4559eba17cd977aa0db658d25c4047067444b51acfdcbf38470630642b23","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3ffabc95802521e1e4bcba4c88d8615176dc6e09111d920c7a213bdda6e1d65","impliedFormat":1},{"version":"809821b8a065e3234a55b3a9d7846231ed18d66dd749f2494c66288d890daf7f","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"c3b41e74b9a84b88b1dca61ec39eee25c0dbc8e7d519ba11bb070918cfacf656","affectsGlobalScope":true,"impliedFormat":1},{"version":"4737a9dc24d0e68b734e6cfbcea0c15a2cfafeb493485e27905f7856988c6b29","affectsGlobalScope":true,"impliedFormat":1},{"version":"36d8d3e7506b631c9582c251a2c0b8a28855af3f76719b12b534c6edf952748d","impliedFormat":1},{"version":"1ca69210cc42729e7ca97d3a9ad48f2e9cb0042bada4075b588ae5387debd318","impliedFormat":1},{"version":"f5ebe66baaf7c552cfa59d75f2bfba679f329204847db3cec385acda245e574e","impliedFormat":1},{"version":"ed59add13139f84da271cafd32e2171876b0a0af2f798d0c663e8eeb867732cf","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7c5e2ea4a9749097c347454805e933844ed207b6eefec6b7cfd418b5f5f7b28","impliedFormat":1},{"version":"b1810689b76fd473bd12cc9ee219f8e62f54a7d08019a235d07424afbf074d25","impliedFormat":1},{"version":"f9fd93190acb1ffe0bc0fb395df979452f8d625071e9ffc8636e4dfb86ab2508","impliedFormat":1},{"version":"5f41fd8732a89e940c58ce22206e3df85745feb8983e2b4c6257fb8cbb118493","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"1cfa8647d7d71cb03847d616bd79320abfc01ddea082a49569fda71ac5ece66b","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"413df52d4ea14472c2fa5bee62f7a40abd1eb49be0b9722ee01ee4e52e63beb2","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"446a50749b24d14deac6f8843e057a6355dd6437d1fac4f9e5ce4a5071f34bff","impliedFormat":1},{"version":"182e9fcbe08ac7c012e0a6e2b5798b4352470be29a64fdc114d23c2bab7d5106","impliedFormat":1},{"version":"2f4e6b4d39426a1b85ecf4bdeb9dddbf4d9b3397d95d8555d46f925c9519ec7d","impliedFormat":1},{"version":"78a2869ad0cbf3f9045dda08c0d4562b7e1b2bfe07b19e0db072f5c3c56e9584","impliedFormat":1},{"version":"89d5d28d4f57e000b836ac273079be1b75710e28ce14750d081fb420d37e2ca5","impliedFormat":1},{"version":"fd4e24ccff3966390600d7f5d6aa1fed5a512e92ada735ea5fbc933d313ad3d3","impliedFormat":1},{"version":"b7cddfe1aa6b86b5fad3c9ccb30d05b3ccb165aebbf112f48d2d8a5f69dd98b1","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"ad0d1d75d129b1c80f911be438d6b61bfa8703930a8ff2be2f0e1f8a91841c64","impliedFormat":1},{"version":"bd2c7ada3dee03653d3f601011d30072194bc3970cd93208f9588fbdc0c69347","impliedFormat":1},{"version":"e480da45d32313e7174b265674da504f075f59ef326852f0c5a5d863b438ae85","impliedFormat":1},{"version":"ad54850f61fcf5d014e11be80d2f46fea9265cfa7e77456da876f7833ef81769","impliedFormat":1},{"version":"6f7c9e8bd2b5b6a080b07080065f94900bd3c7e5ebbd3047bc33fcce2fab1dd8","impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","impliedFormat":1},{"version":"da5950ee2a90721df6f3fba45f5d05308f7e4c35835392215dd2cd404505e2de","impliedFormat":1},{"version":"ce75b1aebb33d510ff28af960a9221410a3eaf7f18fc5f21f9404075fba77256","impliedFormat":1},{"version":"f42d5fed19610d485c646a0c430e768115567d078c7fc855c57b0c578b3d6cd3","impliedFormat":1},{"version":"ee8df1cb8d0faaca4013a1b442e99130769ce06f438d18d510fed95890067563","impliedFormat":1},{"version":"d5630f2ad9b4541e5ce891648121022f9412ecdca1820baa1f0104f70fd7eff7","impliedFormat":1},{"version":"4d15375ab13497104bc8fe56fdef2b5fd6853f29255737d23a33fa306ff7fd69","impliedFormat":1},{"version":"2cd3fc1d0d6a1e85baffd2d4f50f5efb192b5446eef567e97c94765402f0aad4","impliedFormat":1},{"version":"e4cbf2f1e89ecccaddd2c045e600ae41b732295953fb06247c7dcbc2d281ed30","impliedFormat":1},{"version":"6dcedaef57dff0d79a05ab0ab602cde74db803d1e765468bf91263786a383e1b","impliedFormat":1},{"version":"8c1697d90c394a6fd955b98eae01238eff628e129b987a68aea10f898a48e7da","impliedFormat":1},{"version":"7580e62139cb2b44a0270c8d01abcbfcba2819a02514a527342447fa69b34ef1","impliedFormat":1},{"version":"b838d4c72740eb0afd284bf7575b74c624b105eff2e8c7b4aeead57e7ac320ff","impliedFormat":1},{"version":"f374cb24e93e7798c4d9e83ff872fa52d2cdb36306392b840a6ddf46cb925cb6","impliedFormat":1},{"version":"d10d63718e1646c2279e3b33831f82c60e31f622b2b7020f1196409ca4c09242","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"148679c6d0f449210a96e7d2e562d589e56fcde87f843a92808b3ff103f1a774","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"02436d7e9ead85e09a2f8e27d5f47d9464bced31738dec138ca735390815c9f0","impliedFormat":1},{"version":"f8d5ff8eafd37499f2b6a98659dd9b45a321de186b8db6b6142faed0fea3de77","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"a22dd55aa4d39906252000ab8e8a1b83b195eef7f4274eb51e457c1f11cf6580","impliedFormat":1},{"version":"540cc83ab772a2c6bc509fe1354f314825b5dba3669efdfbe4693ecd3048e34f","impliedFormat":1},{"version":"121b0696021ab885c570bbeb331be8ad82c6efe2f3b93a6e63874901bebc13e3","impliedFormat":1},{"version":"612d9da66bb046a9c1e2e8d026245ded881fc4b9f98cbfae714415d57ee0ae0b","impliedFormat":1},{"version":"32c2ad9494dad5d11b0564a619fee18f388db6c1e9e2cd3c360b3122549691eb","impliedFormat":1},{"version":"6c301d40aec56a74ec7bd7324e31a728dadf9bfba3e96def02938d3d973534ec","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"aa14cee20aa0db79f8df101fc027d929aec10feb5b8a8da3b9af3895d05b7ba2","impliedFormat":1},{"version":"493c700ac3bd317177b2eb913805c87fe60d4e8af4fb39c41f04ba81fae7e170","impliedFormat":1},{"version":"aeb554d876c6b8c818da2e118d8b11e1e559adbe6bf606cc9a611c1b6c09f670","impliedFormat":1},{"version":"acf5a2ac47b59ca07afa9abbd2b31d001bf7448b041927befae2ea5b1951d9f9","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"d71291eff1e19d8762a908ba947e891af44749f3a2cbc5bd2ec4b72f72ea795f","impliedFormat":1},{"version":"c0480e03db4b816dff2682b347c95f2177699525c54e7e6f6aa8ded890b76be7","impliedFormat":1},{"version":"25a5f6fd3a2243c859eddc99ab5fba11d970af2fe7a5df9c32b7668f76f97b01","impliedFormat":1},{"version":"8d207e1f9d2c30d6f77dfa693f3827c3fbf0d89240297e10bdfe1041d433df68","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","impliedFormat":1},{"version":"d682336018141807fb602709e2d95a192828fcb8d5ba06dda3833a8ea98f69e3","impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","impliedFormat":1},{"version":"0d891735a21edc75df51f3eb995e18149e119d1ce22fd40db2b260c5960b914e","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"4fbd3116e00ed3a6410499924b6403cc9367fdca303e34838129b328058ede40","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"8c70ddc0c22d85e56011d49fddfaae3405eb53d47b59327b9dd589e82df672e7","impliedFormat":1},{"version":"2f9c89cbb29d362290531b48880a4024f258c6033aaeb7e59fbc62db26819650","impliedFormat":1},{"version":"a365c4d3bed3be4e4e20793c999c51f5cd7e6792322f14650949d827fbcd170f","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"273782b8454e78f6a8b30d2cfbf6860499c930595095fcc1689637115f0eddda","affectsGlobalScope":true,"impliedFormat":1},{"version":"3fbdd025f9d4d820414417eeb4107ffa0078d454a033b506e22d3a23bc3d9c41","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"a8f8e6ab2fa07b45251f403548b78eaf2022f3c2254df3dc186cb2671fe4996d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"9f9bb6755a8ce32d656ffa4763a8144aa4f274d6b69b59d7c32811031467216e","impliedFormat":1},{"version":"5c32bdfbd2d65e8fffbb9fbda04d7165e9181b08dad61154961852366deb7540","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"0c05e9842ec4f8b7bfebfd3ca61604bb8c914ba8da9b5337c4f25da427a005f2","impliedFormat":1},{"version":"faed7a5153215dbd6ebe76dfdcc0af0cfe760f7362bed43284be544308b114cf","impliedFormat":1},{"version":"7029e566b8df176f703fb59fd437a38670c7a0e02c58b2d66dfb5b2e2b2defdb","impliedFormat":1},{"version":"7f2aa4d4989a82530aaac3f72b3dceca90e9c25bee0b1a327e8a08a1262435ad","impliedFormat":1},{"version":"d96b39301d0ded3f1a27b47759676a33a02f6f5049bfcbde81e533fd10f50dcb","impliedFormat":1},{"version":"e9f147ecca73d9346a4c073432843c159ccbe50bdcb678a78f6da10eae2cecf4","impliedFormat":1},{"version":"de061f7d72bd65c06fc1419f841dfdcb29a8e22fe6fa527d1e6eb20b897d4de0","impliedFormat":1},{"version":"663beafc2446079574570cba86e9b15f986f908ddb1b01274509970126fee945","impliedFormat":1},{"version":"a3102887d5058bf4cb5b37fa6964c09e9527c42053b3b5c642b89878620748de","impliedFormat":1},{"version":"0aaaa1727edd29673d85c9b26d7ca4d54e5407a48586903c51b48b7f7d196f61","impliedFormat":1},{"version":"d35bca0b261bff02635758c48e8ab99c61c420d0dfabbcf467e847171d876b7d","impliedFormat":1},{"version":"3bc12c40d90c342ff88a3d876996c555ed5cbee5fe8c3308a240b321f401ee46","impliedFormat":1},{"version":"ba130768aae855a5477e9e148e5c879548e6e7ccbcc56fd1934c8a18ea5b7569","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"d38530db0601215d6d767f280e3a3c54b2a83b709e8d9001acb6f61c67e965fc","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"b499af2054a037a162b3b72cd886f48bbf32a3502c865c6e29fac7d2ab3ce0b5","impliedFormat":1},{"version":"b83cb14474fa60c5f3ec660146b97d122f0735627f80d82dd03e8caa39b4388c","impliedFormat":1},{"version":"48773ca557b0319c2ee62ae249cf52a81709e8be139920d6479a66274de7c4ed","impliedFormat":1},{"version":"7274fbffbd7c9589d8d0ffba68157237afd5cecff1e99881ea3399127e60572f","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"8b4327413e5af38cd8cb97c59f48c3c866015d5d642f28518e3a891c469f240e","impliedFormat":1},{"version":"4cceef18d7f088e797a463e90b7a9dad10c6bc667724b7686e3e740ae00122be","impliedFormat":1},{"version":"7ee86fbb3754388e004de0ef9e6505485ddfb3be7640783d6d015711c03d302d","impliedFormat":1},{"version":"cc1954b539604b1e562319119ac7e888172208b32ca873f9a357a92c826bd046","impliedFormat":1},{"version":"a67b87d0281c97dfc1197ef28dfe397fc2c865ccd41f7e32b53f647184cc7307","impliedFormat":1},{"version":"771ffb773f1ddd562492a6b9aaca648192ac3f056f0e1d997678ff97dbb6bf9b","impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","impliedFormat":1},{"version":"232f70c0cf2b432f3a6e56a8dc3417103eb162292a9fd376d51a3a9ea5fbbf6f","impliedFormat":1},{"version":"bb8f2dbc03533abca2066ce4655c119bff353dd4514375beb93c08590c03e023","impliedFormat":1},{"version":"706dd95827e7ebaabda91d5db2b755233e0952d98570e9c032b0f066a15c1177","affectsGlobalScope":true,"impliedFormat":1},{"version":"0b103e9abfe82d14c0ad06a55d9f91d6747154ef7cacc73cf27ecad2bfb3afcf","impliedFormat":1},{"version":"cd9304972e6d616197fb44fce00540a904f38b54306a1951b5dbeaf3c01ab5bd","impliedFormat":1},{"version":"77438e2c397a3db78407621cfc57241a305b310ddea2c185f1d555248297f587","impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"a6805fcafed712aea7759f8bc731014f9d22738c1d6ef9d43b8091d1d48346d5","impliedFormat":1},{"version":"c49469a5349b3cc1965710b5b0f98ed6c028686aa8450bcb3796728873eb923e","impliedFormat":1},{"version":"4a889f2c763edb4d55cb624257272ac10d04a1cad2ed2948b10ed4a7fda2a428","impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","impliedFormat":1},{"version":"d88ea80a6447d7391f52352ec97e56b52ebec934a4a4af6e2464cfd8b39c3ba8","impliedFormat":1},{"version":"142617b3cdf902b69c6464c9fbd942b60ab3e733ca18c032b19e0f7e2adbefe8","impliedFormat":1},{"version":"0b603555f1881f87256ffd6344d3e3ed6d466c2e701eabf381f28be8c2125892","impliedFormat":1},{"version":"897e4f7662488e3ecc79e743bdd3b78f13bdb69a97851afa5b440c4211e32ea9","impliedFormat":1},{"version":"e2e1c6d3b2d93add5200bd7bc1a8cccb4e446836b2111ece45db8683a2c765de","impliedFormat":1},{"version":"251b03d5cd243854ce870d9a9a39f491faf69898c5d6b5eee28cc7649c57417b","impliedFormat":1},{"version":"27ff4196654e6373c9af16b6165120e2dd2169f9ad6abb5c935af5abd8c7938c","impliedFormat":1},{"version":"2c4de79f406d137390608e8c0a44fba2ff8e00bacfcae7c9d1781fef10e9440d","impliedFormat":1},{"version":"07ba23a10465791be5d22deaf5ef7de7658774ddff53721e5ea17fedea1bc721","impliedFormat":1},{"version":"dca8c645c5afeb03b1ecedbf16323f33e7d0afaa6256c8e047e6e38087a97f53","impliedFormat":1},{"version":"775f181bd4a533d6f8b5e55ec1d9f1624559720ae8a70e9432258da26b38d27c","impliedFormat":1},{"version":"796273b2edc72e78a04e86d7c58ae94d370ab93a0ddf40b1aa85a37a1c29ecd7","impliedFormat":1},{"version":"5df15a69187d737d6d8d066e189ae4f97e41f4d53712a46b2710ff9f8563ec9f","impliedFormat":1},{"version":"7715134a0cf07dd41a9da2895d708625a3a303a0385e355ecaaf0b8bfaef2550","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"622694a8522b46f6310c2a9b5d2530dde1e2854cb5829354e6d1ff8f371cf469","impliedFormat":1},{"version":"cd8ce8d68567f62dd580b3c3c37777ac3f5b81944c7417f5ea83030eab533385","impliedFormat":1},{"version":"e5c939d896565dcac0f6fbdbada11284e7728ef26a069561c09aa5aa4a788393","impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","impliedFormat":1},{"version":"a9e6c0ff3f8186fccd05752cf75fc94e147c02645087ac6de5cc16403323d870","impliedFormat":1},{"version":"49af4b52f0d4d2304c5f2c6fe5fab3e153e0acc38830d0202821b877c097dd02","impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","impliedFormat":1},{"version":"e68b8e5a1df7c1be2bc105141456ecba70215806e1c28bfbc5c12bfce4be6e68","impliedFormat":1},{"version":"511c8f02329808d47d00b859c532ae9115590048b17325a946c74dac48428650","impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","impliedFormat":1},{"version":"b5f9e66625783eefcbe3d2da074b2e7ba2066d61ce3fc6ef4f22805ad946cab4","impliedFormat":1},{"version":"e37115962d284b9f7a37c2bdd2add50f88365dde41f5e0ff591ffc48a8ec7575","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","impliedFormat":1},{"version":"f89488602bec98a142072fae7ea5ba99431a569ff580c64b7be39896474799d8","impliedFormat":1},{"version":"bbbc47961f39a57df103cf4ca3bb8f8732b4b6678a18225a0aa76d59c466956c","impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","impliedFormat":1},{"version":"2ffb043dc5163458e473b7010859f86e01dc4edffcae0a93d885d028b426a546","impliedFormat":1},{"version":"c8f004e6036aa1c764ad4ec543cf89a5c1893a9535c80ef3f2b653e370de45e6","impliedFormat":1},{"version":"dd80b1e600d00f5c6a6ba23f455b84a7db121219e68f89f10552c54ba46e4dc9","impliedFormat":1},{"version":"b064c36f35de7387d71c599bfcf28875849a1dbc733e82bd26cae3d1cd060521","impliedFormat":1},{"version":"05c7280d72f3ed26f346cbe7cbbbb002fb7f15739197cbbee6ab3fd1a6cb9347","impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","impliedFormat":1},{"version":"04b7b2e0832dfd3c31e81df3975e8d8fda28e7ff999b0aa2932608a8f6661d5c","impliedFormat":1},{"version":"ca2d34c6ed5cbd3070b8b6f32f42ae54adcc6499c1e4b99f0a5798b3f27cc653","impliedFormat":1},{"version":"9ec68995e66dd6b9dac834bf5ae85fde802714ea2e82151a5d1d53ef01b463ef","impliedFormat":1},{"version":"5c4d626b4902f2ef8a1cc146d761d276cef988016dc674e3b98fbad70e64bc9f","impliedFormat":1},{"version":"fdfaa0aad899524962e2955287b5b991ffe3be50f64e02eb60c933ca44644a94","impliedFormat":1},{"version":"53c972a0f9bc3a4ec70fff7314123ea8cfcf75b3703046f767d2dc1eea87b2fb","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","impliedFormat":1},{"version":"7d73b24e7bf31dfb8a931ca6c4245f6bb0814dfae17e4b60c9e194a631fe5f7b","impliedFormat":1},{"version":"d130c5f73768de51402351d5dc7d1b36eaec980ca697846e53156e4ea9911476","impliedFormat":1},{"version":"413586add0cfe7369b64979d4ec2ed56c3f771c0667fbde1bf1f10063ede0b08","impliedFormat":1},{"version":"06472528e998d152375ad3bd8ebcb69ff4694fd8d2effaf60a9d9f25a37a097a","impliedFormat":1},{"version":"7303b45138d2511035056a5901a1490ebdcbf055cbb1276f8629c5121cbe733e","impliedFormat":1},{"version":"27f874cd5327507eeff699a74567f60c1215b94509f4308633a7b01922471ed2","impliedFormat":1},{"version":"a401617604fa1f6ce437b81689563dfdc377069e4c58465dbd8d16069aede0a5","impliedFormat":1},{"version":"2c6cf04bc525caf6546e859e8ef10bfb9573837ec0bc5ec7b53a7b1b8ca72781","impliedFormat":1},{"version":"8695dec09ad439b0ceef3776ea68a232e381135b516878f0901ed2ea114fd0fe","impliedFormat":1},{"version":"304b44b1e97dd4c94697c3313df89a578dca4930a104454c99863f1784a54357","impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","impliedFormat":1},{"version":"87cc05fe13108f02e12da7e3efd8e360fef78d96a0c9e11408ea1b1b9fb3e03d","impliedFormat":1},{"version":"1abbf67c218d23c2ce76887caac2df6c7dab3d97ba2b65348432b876f510002a","impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","impliedFormat":1},{"version":"4b20fcf10a5413680e39f5666464859fc56b1003e7dfe2405ced82371ebd49b6","impliedFormat":1},{"version":"c06ef3b2569b1c1ad99fcd7fe5fba8d466e2619da5375dfa940a94e0feea899b","impliedFormat":1},{"version":"f7d628893c9fa52ba3ab01bcb5e79191636c4331ee5667ecc6373cbccff8ae12","impliedFormat":1},{"version":"1d879125d1ec570bf04bc1f362fdbe0cb538315c7ac4bcfcdf0c1e9670846aa6","impliedFormat":1},{"version":"dad97c99382889e9c7d1a9d8275500ff71235130fae9f8916fdbf3641d56e592","impliedFormat":1},{"version":"a6dba407fc287f1e25454e75028c91bbc00675f2d1c4e8b3edcc36c08611a486","impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","impliedFormat":1},{"version":"e91f7b1344577a02f051b9b471f33044fef8334a76dc9e1de003d17595a5219b","impliedFormat":1},{"version":"c0723195c85e19656d6b5b9fdb81d3f3403c1ae4679e722c6ea058c516b38d12","impliedFormat":1},{"version":"b55eb9f72166093b5460d34b34f5d8699c968de3bc3fc696e40f2c93f2ebf650","impliedFormat":1},{"version":"71d9eb4c4e99456b78ae182fb20a5dfc20eb1667f091dbb9335b3c017dd1c783","impliedFormat":1},{"version":"cfa846a7b7847a1d973605fbb8c91f47f3a0f0643c18ac05c47077ebc72e71c7","impliedFormat":1},{"version":"1594da19968752a22b2ac48c2d0e60575700e745c577a8a4a676b841238ad5bb","impliedFormat":1},{"version":"e0cee12109e0a10a4c3d6769fcc7644b7c1ea7f52365bea51728f5af29f8a137","impliedFormat":1},{"version":"7d4254b4c6c67a29d5e7f65e67d72540480ac2cfb041ca484847f5ae70480b62","impliedFormat":1},{"version":"3536968defef8a75514f547ead5e2e9c1e984820290ec9b00c5fdfb6ef786535","impliedFormat":1},{"version":"d83773870080c30a230e322ce13a9c6f3398e8dacea4ea8a83e26370f3bac23e","impliedFormat":1},{"version":"dcfeaf98d66314fec29a9076c4290e45d0b196a65827becc19138e9c7b855f37","impliedFormat":1},{"version":"6849fe9210fe4946d5f085bfed36758f33dc6ae15a751338d178dd4daa017c46","impliedFormat":1},{"version":"888cda0fa66d7f74e985a3f7b1af1f64b8ff03eb3d5e80d051c3cbdeb7f32ab7","impliedFormat":1},{"version":"60681e13f3545be5e9477acb752b741eae6eaf4cc01658a25ec05bff8b82a2ef","impliedFormat":1},{"version":"ffae4e1e06aa848a1e4bcef162cd1c48e5909b26223515981310af9c036bdfc7","impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","impliedFormat":1},{"version":"34e16eb7c31768a11a08aebcfb3d70d7b8f0b016197e98d8419e566ceae6d6c8","impliedFormat":1},{"version":"f94ec1f7e4b709d26960306c9082a7a1b728a6e13089346aa48ba57c74cbf47e","impliedFormat":1},{"version":"9a11cb4033405e96c247cd5aa29790212aaffdd127869e8a5219103f0b389fd5","impliedFormat":1},{"version":"01479d9d5a5dda16d529b91811375187f61a06e74be294a35ecce77e0b9e8d6c","impliedFormat":1},{"version":"aff5213585cb72e94054dfe17250ff315f3569b3919d1ef1ad235f37c4ee894e","impliedFormat":1},{"version":"fb2ea35e1be6388d722d7725e2b49c697d34d9c890c3b96758faaeb86d35cef8","impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","impliedFormat":1},{"version":"1a4dc28334a926d90ba6a2d811ba0ff6c22775fcc13679521f034c124269fd40","impliedFormat":1},{"version":"f05315ff85714f0b87cc0b54bcd3dde2716e5a6b99aedcc19cad02bf2403e08c","impliedFormat":1},{"version":"5fad3b31fc17a5bc58095118a8b160f5260964787c52e7eb51e3d4fcf5d4a6f0","impliedFormat":1},{"version":"72105519d0390262cf0abe84cf41c926ade0ff475d35eb21307b2f94de985778","impliedFormat":1},{"version":"456006a6975b26c0a1785feddae165f6d307e2d601ffde27e21fc4a790e448a4","impliedFormat":1},{"version":"c857e0aae3f5f444abd791ec81206020fbcc1223e187316677e026d1c1d6fe08","impliedFormat":1},{"version":"ccf6dd45b708fb74ba9ed0f2478d4eb9195c9dfef0ff83a6092fa3cf2ff53b4f","impliedFormat":1},{"version":"1fe0d18b111e1145a7e7601855bccd4ca20f24e3b9a5aba6bb1fa9d1a7059170","impliedFormat":1},{"version":"5632c3c26d420c063eebe64c45b1248b9492a67bf44f1d0c57e9dc8f6cf449bb","impliedFormat":1},{"version":"0df5aa619ab12993a39ea6dae062ee46eadbb4d738916460e636ada52bced75b","impliedFormat":1},{"version":"8fca3039857709484e5893c05c1f9126ab7451fa6c29e19bb8c2411a2e937345","impliedFormat":1},{"version":"35069c2c417bd7443ae7c7cafd1de02f665bf015479fec998985ffbbf500628c","impliedFormat":1},{"version":"10ab7be91f87ebe8916b62cf28af2e45b5601fc7b0e311adf838f912c6b31dd8","impliedFormat":1},{"version":"bc636fbc08e0979ceb7eb0731a33000283d77a33b62e1f71ee65be50394e40ba","impliedFormat":1},{"version":"7e0b7f91c5ab6e33f511efc640d36e6f933510b11be24f98836a20a2dc914c2d","impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"75bbd3be047d539988a0ff0b56384ef7a6a25f3b676ad96bee547d44c31622a7","impliedFormat":1},{"version":"42960001a776b089ade681ab5cfddc936e0afb0615133ec1841f3dee89d3e1bf","impliedFormat":1},{"version":"0aedb02516baf3e66b2c1db9fef50666d6ed257edac0f866ea32f1aa05aa474f","impliedFormat":1},{"version":"da47712b394d944328245482603bc6f416d3949b67c9392279caab595076b510","affectsGlobalScope":true,"impliedFormat":1},{"version":"37d0071d8f0a06dc55c2c5e0ec3391affd4fd107c53410bf358196ec0bf3923f","impliedFormat":1},{"version":"b213dad76ca37fd552274c9499056e1c0d9c1bd38a55bb7f68b22ba6b84c3ad7","impliedFormat":1},{"version":"c30436b130b6218b7714314dc41d3f459590db4bdf099eecd51cb1bda32109a8","impliedFormat":1},{"version":"d90b9f1520366d713a73bd30c5a9eb0040d0fb6076aff370796bc776fd705943","impliedFormat":1},{"version":"05321b823dd3781d0b6aac8700bfdc0c9181d56479fe52ba6a40c9196fd661a8","impliedFormat":1},{"version":"736a8712572e21ee73337055ce15edb08142fc0f59cd5410af4466d04beff0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"bef86adb77316505c6b471da1d9b8c9e428867c2566270e8894d4d773a1c4dc2","impliedFormat":1},{"version":"5a49adaef698b7ad7e6127949fa1b0bbd3d46b7cbd11c54e392a4dcdd51f5190","impliedFormat":1},{"version":"6ee598cdfdd0fa52039dca135b3dfff7b49035dc13292143e0a93843e3861967","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","impliedFormat":1},{"version":"2489bf04d77dc025ba67f49f1a56eb24b9db477d5ff88123d887e163ed1776aa","impliedFormat":1},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","impliedFormat":1},{"version":"0b77b819b5417775fccb20c678293cf614c054a5b1a65421a5b933a9124ba998","impliedFormat":1},{"version":"eb5acb58487367e502d994b57e2c58255d8241f481ea8efa8e79af23af3f41c2","impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","impliedFormat":1},{"version":"b1f1d57fde8247599731b24a733395c880a6561ec0c882efaaf20d7df968c5af","impliedFormat":1},{"version":"f1f4095f04343ad6e6825eba41eb50f1555685560dde164179a467e65c4a921c","impliedFormat":1},{"version":"35e6379c3f7cb27b111ad4c1aa69538fd8e788ab737b8ff7596a1b40e96f4f90","impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","impliedFormat":1},{"version":"5a3ea721d03a361ccbdd7390ccd75f6e84cbca3a3f01f4b331ecc9af31890c49","impliedFormat":1},{"version":"e7dfaee4af38d45b1cab8a1ee0b3bc1f85ddcf64545ed391d675d78ae6526274","affectsGlobalScope":true,"impliedFormat":1},{"version":"e8daa443eaf9a27fd382cc1f8ebe30330c0f4d89511cfb469166874806751d35","impliedFormat":1},{"version":"af48e58339188d5737b608d41411a9c054685413d8ae88b8c1d0d9bfabdf6e7e","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"1de8c302fd35220d8f29dea378a4ae45199dc8ff83ca9923aca1400f2b28848a","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","impliedFormat":1},{"version":"332248ee37cca52903572e66c11bef755ccc6e235835e63d3c3e60ddda3e9b93","impliedFormat":1},{"version":"94e8cc88ae2ef3d920bb3bdc369f48436db123aa2dc07f683309ad8c9968a1e1","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"320f4091e33548b554d2214ce5fc31c96631b513dffa806e2e3a60766c8c49d9","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"d90d5f524de38889d1e1dbc2aeef00060d779f8688c02766ddb9ca195e4a713d","impliedFormat":1},{"version":"07ed3ddab975995eea41b22f3010506fb9f5fb301d04820b07d7a1aee5477d7c","impliedFormat":1},{"version":"969d8b0965849f4bae7cab0ba90bd1e1220e95999c2c6f01117fa7500901c017","impliedFormat":1},{"version":"6ec840ee5e2bc103f557fe38b1d585ee250540468713d7634ee066de372bf332","impliedFormat":1},{"version":"b0309e1eda99a9e76f87c18992d9c3689b0938266242835dd4611f2b69efe456","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"6ceb10ca57943be87ff9debe978f4ab73593c0c85ee802c051a93fc96aaf7a20","impliedFormat":1},{"version":"1de3ffe0cc28a9fe2ac761ece075826836b5a02f340b412510a59ba1d41a505a","impliedFormat":1},{"version":"e46d6cc08d243d8d0d83986f609d830991f00450fb234f5b2f861648c42dc0d8","impliedFormat":1},{"version":"1c0a98de1323051010ce5b958ad47bc1c007f7921973123c999300e2b7b0ecc0","impliedFormat":1},{"version":"ff863d17c6c659440f7c5c536e4db7762d8c2565547b2608f36b798a743606ca","impliedFormat":1},{"version":"5412ad0043cd60d1f1406fc12cb4fb987e9a734decbdd4db6f6acf71791e36fe","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"e297c0a524edee7677939122f90027bfbe5f2698939d9a85728e5044b39c7124","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"bc9ee0192f056b3d5527bcd78dc3f9e527a9ba2bdc0a2c296fbc9027147df4b2","impliedFormat":1},{"version":"b62381cae176db34f003cc6172ee8f3e0122014889d66391aa73698105cf4934","impliedFormat":1},{"version":"1d9c0a9a6df4e8f29dc84c25c5aa0bb1da5456ebede7a03e03df08bb8b27bae6","impliedFormat":1},{"version":"84380af21da938a567c65ef95aefb5354f676368ee1a1cbb4cae81604a4c7d17","impliedFormat":1},{"version":"1af3e1f2a5d1332e136f8b0b95c0e6c0a02aaabd5092b36b64f3042a03debf28","impliedFormat":1},{"version":"30d8da250766efa99490fc02801047c2c6d72dd0da1bba6581c7e80d1d8842a4","impliedFormat":1},{"version":"03566202f5553bd2d9de22dfab0c61aa163cabb64f0223c08431fb3fc8f70280","impliedFormat":1},{"version":"41eb514d9ce0a6e87957f08a4b7af70d93f87637f37dee706e2d92a6601c25a9","impliedFormat":1},{"version":"e7765aa8bcb74a38b3230d212b4547686eb9796621ffb4367a104451c3f9614f","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"5bf5c7a44e779790d1eb54c234b668b15e34affa95e78eada73e5757f61ed76a","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"7bd01f0f28cd3aeb2046274d85208e245965f6f2948edf4f7b2057bcf9f22ccc","impliedFormat":99},{"version":"d2f2cf2b8cc92bea913cda4a076e0f790b23a21e84f989d12f0116a7fe3906e0","impliedFormat":99},{"version":"6de125ea94866c736c6d58d68eb15272cf7d1020a5b459fea1c660027eca9a90","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5b20bc288ee49989c95b20847fc93b96bf61cc0845598897a6a53a967dd7d07","affectsGlobalScope":true,"impliedFormat":1},{"version":"064ac1c2ac4b2867c2ceaa74bbdce0cb6a4c16e7c31a6497097159c18f74aa7c","impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","impliedFormat":1},{"version":"d3b315763d91265d6b0e7e7fa93cfdb8a80ce7cdd2d9f55ba0f37a22db00bdb8","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},{"version":"eda39e114f754baf0293d35b441fd5ef6112588808550d3cbb47ad926d3ce8b4","affectsGlobalScope":true},"7b550dda9686c16f36a17bf9051d5dbf31e98555b30d114ac49fc49a1e712651",{"version":"06056b049d4e61086019c0c0538c05e431c92ab5acdb249bb3e67676face01ef","signature":"435a1e418e8338be3f39614b96b81a9aa2700bc8c27bc6b98f064ff9ce17c363"},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"e3cf0611709328b449ec13f8c436712d62003620ce480139fae46ce001c2ee9f","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","impliedFormat":1},{"version":"b0dee183d4e65cf938242efaf3d833c6b645afb35039d058496965014f158141","impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","impliedFormat":1},{"version":"0db0fb120ce9cbcaa9606631141348a5b995d1c7fe359f16bcb618dac13223b9","signature":"f65ce75c9085571e6321abf2bf9833709f4897e381f89e9925521833dbb7ab16"},{"version":"c57b441e0c0a9cbdfa7d850dae1f8a387d6f81cbffbc3cd0465d530084c2417d","impliedFormat":99},{"version":"344e08bc7b0d72650e79e5926b666e263c36255c775425ebab94a3f76cff1e7b","signature":"e06e7c0f772f7a56bdf0600737ecfc5adf2668f12e7f498cda0f662f7c5fbbcd"},{"version":"8c71045ceadddc56ecdae8427c0dc03bbd19a10e034fac330675906aece2e841","signature":"44e866627a607401f11a9db6acf9b1b710be88281ba7538a5ae933a8e783f8ae"},{"version":"822f8f43960dd8b6bf024f4e32ca192d5e0c01e80b308acc34e1255bd644d012","signature":"032fae6e5f02f0c6c4a06232f2834889f6f01b098a36d351b4187883cea1b085"},{"version":"913933f803186c7e0f998a17f392687200e87f3faff61a1a524503a78a4cff2f","signature":"2312772b1a696a819ed8acdaf8c142cd115b6673afc0e41e7a2dcfc07f3bdd96"},{"version":"28fd46790503759c1fe0764bacb9f5fbd8470f54347696c581000459e0f8c272","signature":"5af2fff0293955aa7d999d42b7a2a51ebc8c4bac40454b01e2fe0fdbaffad73d"},"01d77ff11f1b8f6d4bed553cb67aa414b10e9fe71e593bb567cc202e29bb0aef",{"version":"395a700d2dfd2618018a4ece69c9c1fc8a9155e793ca1881cce9d3b9c8f183e6","signature":"b27fe7e08ff0c461f3005ddc9ca4299ccffed17fdecc49c230efd764025d04c0"},{"version":"38570c88b7531c1bf227334ff8ece6dbc3bffd14714ef705afc4157cbc5d44f4","signature":"300c6832d9efa5359d3c01d7310066634f14144d7c7a6100a611d778ab9ee729"},{"version":"73c078fcbc0fa04ba70b1c3e5a3dea6a980d8765079cdbfb40f903eb8daa4319","impliedFormat":99},{"version":"5297e84d3de08bbe3c00f964d1c74f89cf101d59a4826b335654f44ff41529a8","impliedFormat":99},{"version":"355b33af59287683501f76cbf7d6a141544c5ff1ae5f5c0701a3f89cc38e5238","impliedFormat":99},{"version":"280a996092ab956e80dc7bb7497d472ca5c1be23a9c52ac771f5c750ede462b9","impliedFormat":99},{"version":"3b57d4625b2320c94f210a74a7335236696d83fb00849fdbbc5d3727461b7924","signature":"ee1edfbead6745405d328658287b6e8f957bee22c3217398ca713d6d02d8249f"},{"version":"1494a149921fd88b2b9617eb95b07b36fbf30a1b9c20e3d67673a128bed2a9ee","signature":"963991fa5943adde556cea8ebb00bbc508e9de048eb361c5a8b9c59a88707119"},{"version":"135c7d14b681337e28ba567f1a66bd5e03dce2ed8db58e7b2574c1fdb040fc15","signature":"ba54342937eb116567785cc93f6ef7c8ce3b041a67bb2ecb579ab28359b06047"},{"version":"a02aaec8d196c373890cb6b0b399507a53357cf3cc5102115fb0ba7a4cd825b0","signature":"c6c401f2ad421323c0b6350bc2569963b6ed4f5a8e9e5b31160f32e31935718d"},{"version":"83fd65fee2246e130a91b1b50076833e72dafd4483e7dd184292adeb542ea46c","signature":"b64ef997bc9af867a8845b9f6d7b117613ff85cfee4fa6a6b68a2340ccbc7412"},{"version":"60e587a1280a0f5b5948e75055ede3a22b0fa7f6c145299be03fd596f7e315b3","signature":"3a8dade095767abea27f21cb1b7592232978b883cb6d7d9621fb4e9d5e3da7bb"},{"version":"a09b483cdb0471aa79ac4661fc613263e447469bcac324192b6e05d370b7fd89","signature":"8cbda4a43b1653d5a54deaf2f617cdec94d460f3e2d999b1ad335d8b82115a98"},{"version":"6d696c23cd7da1487b756388c2b167b7c644330ac81033b35405f2399518df26","signature":"f5b5d4d2565e15fa4ea35249f6dfac49afe8d18383b09d6f2732010ea11ff387"},{"version":"ac62517c0fcf3bc3e98740a9330955807b5682f1ad323c043a7a3936b1a661fe","signature":"943aaf838f611dab905818c4606637f9df5439c87c6774a1cadfdcecfc465c72"},{"version":"48f9798f59636835f51c3f9d1fa0a871b91f2d63a2b4e7bd7fa321cdb5b2c606","signature":"16674cb07ff73c1208de871eb0756f51139895e5c9e54c8be6a311c5dcf8a12e"},{"version":"cf0b18e4c4acbd0876ff70d84b8d038e431d3d428fb58676ea6199640b6e8f1f","signature":"f04de5eedf97eb64ce8438a50579e407d6683b7bd7335705178455a044505b30"},{"version":"94e089243fde8466e7d81f98b2ee7e961dca8937e813b0d3f3a5165e0d41252a","signature":"a30fee4268c9e3f000178d984828b7d3ea72159194382c0f4c1c2ca1a92cc656"},{"version":"222e2538612b622a35722b916be75760ad4a02c925314c1063abc3bc390f1d51","signature":"7901fa26bfae66d2338bfcb19e7b188f21128157f4dce925f2655b5771af5803"},{"version":"12f5a94716c7d6ccb40c551d38102792e28491deb3dfba3a79d18bd17d96ad70","signature":"35572312c1685de2b1a1c8bb7b61273d04fcd16e4fc541c16fbc0f50a9cbd765"},{"version":"b391194e7b9d252eaa4a2d9319e1f49995d7793823711907584c237ce32626e2","signature":"afc5e066167c0d4e8b2ef422ac8758792865a2abd915e505353ea8b2633fc18b"},{"version":"996281aca4a8f05a886c64911ea16d5ce50081bc16b9f2e75cdfa70a8fd95222","signature":"3871064453d27e0dd4f5ae97b5982981c27982a3e393dde32dca3bcc67f6d949"},{"version":"ab81041ab5fef4781b17b5800394c430df590f091d34c4722c97c45b1100484b","signature":"7794b6ad122cece44d7f137cb49f7f1f99f25f7e971fd7733264f91dd009b0a6"},{"version":"9c6707398b0607874f607655f715c16ce4303c9789e14a372bdbd0de3d7b8a0c","signature":"8d3f0e3ef05a008d60ab31ab08c6ee8e54cad350a1b5357ea9afcc698877fbf9"},{"version":"d1f926ae6d4e7402d3a9f7ed725b439f7abeb58c567e39c0d3875ca832632549","signature":"1b9f3c602be3a54ab79aff5d93df80dc1812814541f5e2ac8ed9a7a24c32de70"},{"version":"ae91a6482bec5c3a0d5ec73da0cf8773c437360d7a9504aa49c6fedd89ae59ed","signature":"13d70d849f7a1147c2670db7529ef97f3c73d3670a411616400b2a4e6f46a12d"},{"version":"68293858dbd621ca15013a858281408cb8ea1b6c7dd91d4170de6e3e1482346b","signature":"260c70d784855c2d72edf1877debb4f9b9d64c3d7cfa2c40d0147f6347407c22"},{"version":"246f0ac2fdc36cbe14b04028b58e5a7f9ec842ec1a8c42b22944404d7877f659","signature":"e703a4a4e34ad3c466e177463f047ca051d94efade95ee0e39a691ea8f92cc90"},{"version":"cfe7f8bae9581d9e1f769c22b7f0c2c2e987a7d56b79de5c68b47e59ef8eceb2","signature":"3a8523bfa20e149df671c3166d3c2e0c8e5b9f015b121a6855deedf209096b5e"},{"version":"c5636230a7cde210b64418d2af80f591847a894259ac35cfa4a68bc0996f2d60","signature":"160235549f27991e10242d0aa6cdddf32d2f415987b45b94fda9d6a38b62f42c"},{"version":"a98870280b5912ff2b3d2a7852b986c5781c82adfb6f66f30c2bbb67a673820f","signature":"7c25d8fd5c6dacaf633be79774bae2abad9aa30a11643f520eb3e43429fc5451"},{"version":"d115bda99a7c1efee6b718b042f4a56dc7bbdf187e1df076912e9592627fea7a","signature":"a628df2c9e9af434376ec32da54c121cb89ead2a47a49dc19f2ae6cb15930b4d"},{"version":"2943307d4adb403fd809e4e2da52699d42b7413ca5f4fb3e4ee217b001bef9d4","signature":"0f0a94a3acd274bbbae9331f8ee1d7fa6ef7e4d9385f10d75369098ce0000038"},{"version":"02887687deaec2b4645dd1b2d747e632cde288b6f14548df91675d60a1c3a1df","signature":"efe890b2983b2533fa3656bb9e7885d67e436d5969bfcd122adbe6074e6b1a90"},{"version":"97ff328603a671b133b0c9f601d1d3d5bffce1e544f850a83bec67ac481ab1b7","signature":"1a68aa479202c9722fb083f09a1bac91983a8c967832a1a5168fb0f79cfdfb40"},{"version":"f58d85255adf27af18ffe6f122e781658c0315d18ad8e747e854a5467a289dc2","signature":"9545b55dc55206dac2e5886b41a7a798e2dee688c3bad0350fb59931ec0cfe07"},{"version":"c20bc9f5a84e19f62a550af9c02e5a4b63a8219ab5dad16b656afd91fecc23c1","signature":"86ff8572d233269b4f6ea940a25dadbf0647e4a260af419f57ea8ddb6daa9cf9"},{"version":"7f6780d15b4684c743f23d24d0d6f0979825c9aaf24366931f908e8d1521df40","signature":"72fd5ef7a70fdddf446421602c26bf27d8bda7d2fd0b4736641a9cedf1ad9348"},{"version":"81ee1614b3b2989901e69823220f2d7af442a200d3e6a2372c5206de99457ccd","signature":"14b178cc7a21759d901ad399d3956ff8064575d2582d31508fc44fd98e8383f1"},{"version":"6897813c4a084ad226b4944a9f33ef6080b1dc4a7c39cc45143b2fed34918a14","signature":"fc6151bf469ebb6e2858df018a898f97cecd7ba9effc52cca518ca9d9f8d4d73"},{"version":"5a00c7fcd78617f397c4ecbfa3b779d1f8b2e21ef3b24e55b2f4a79b2b90b099","signature":"45b373ad2e114de335dd3eaf62f9658266d71c2f34537489f88f3b4815fa72f8"},{"version":"1ff8a7d188edd99e4438cec7a01c326ccf0fd3f7c31e09dcd084fbe9b1211aa0","signature":"6512b35d71042c20407af981ecc7d081377ca7ec5764bef9603f8c6ce28b1a36"},"d1986184a09a52db8228cb2bb2a61a8c05c9354e5b93cec8e2628d8579c892d7",{"version":"06c3d65e9a8c2cf50edfa16494b50ae460b16ac2fd7faa6076c161984ff48840","signature":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881"},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"f3d8c757e148ad968f0d98697987db363070abada5f503da3c06aefd9d4248c1","impliedFormat":1},{"version":"96d14f21b7652903852eef49379d04dbda28c16ed36468f8c9fa08f7c14c9538","impliedFormat":1}],"root":[[529,531],556,[558,565],[570,607]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":4,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[606,1],[529,2],[607,3],[578,4],[580,5],[579,6],[576,7],[584,8],[582,5],[585,8],[581,9],[577,10],[587,5],[586,6],[590,11],[589,5],[591,12],[588,9],[593,13],[594,6],[592,14],[596,15],[600,16],[602,17],[601,18],[597,5],[572,19],[604,20],[605,12],[573,21],[574,22],[583,6],[603,23],[571,24],[575,25],[559,26],[558,27],[560,27],[563,28],[562,23],[561,27],[595,12],[598,17],[599,12],[564,22],[565,22],[570,29],[530,30],[531,31],[375,2],[566,2],[567,32],[568,33],[569,34],[608,2],[609,2],[610,2],[141,35],[142,35],[143,36],[98,37],[144,38],[145,39],[146,40],[93,2],[96,41],[94,2],[95,2],[147,42],[148,43],[149,44],[150,45],[151,46],[152,47],[153,47],[154,48],[155,49],[156,50],[157,51],[99,2],[97,2],[158,52],[159,53],[160,54],[192,55],[161,56],[162,57],[163,58],[164,59],[165,60],[166,61],[167,62],[168,63],[169,64],[170,65],[171,65],[172,66],[173,2],[174,67],[176,68],[175,69],[177,70],[178,71],[179,72],[180,73],[181,74],[182,75],[183,76],[184,77],[185,78],[186,79],[187,80],[188,81],[189,82],[100,2],[101,2],[102,2],[140,83],[190,84],[191,85],[85,2],[196,86],[459,87],[197,88],[195,87],[460,89],[193,90],[194,91],[83,2],[86,92],[457,87],[227,87],[557,2],[84,2],[481,93],[486,1],[493,94],[476,95],[231,2],[239,96],[379,97],[382,98],[354,2],[367,99],[374,100],[256,2],[356,2],[237,2],[353,101],[399,102],[238,2],[229,103],[381,104],[383,105],[384,106],[455,107],[348,108],[301,109],[361,110],[362,111],[360,112],[359,2],[355,113],[380,114],[240,115],[425,2],[426,116],[267,117],[241,118],[268,117],[304,117],[207,117],[377,119],[376,2],[366,120],[471,2],[216,2],[492,121],[433,122],[434,123],[430,124],[510,2],[331,2],[435,23],[431,125],[515,126],[514,127],[509,2],[282,2],[334,128],[333,2],[508,129],[432,87],[287,130],[294,131],[296,132],[286,2],[291,133],[293,134],[295,135],[290,136],[288,2],[292,137],[511,2],[507,2],[513,138],[512,2],[285,139],[502,140],[505,141],[275,142],[274,143],[273,144],[518,87],[272,145],[261,2],[520,2],[521,87],[522,146],[199,2],[363,147],[364,148],[365,149],[203,2],[368,2],[223,150],[198,2],[447,87],[205,151],[446,152],[445,153],[436,2],[437,2],[444,2],[439,2],[442,154],[438,2],[440,155],[443,156],[441,155],[236,2],[233,2],[234,117],[388,2],[393,157],[394,158],[392,159],[390,160],[391,161],[386,2],[453,23],[228,23],[480,162],[487,163],[491,164],[322,165],[321,2],[316,2],[467,166],[475,167],[349,168],[350,169],[428,170],[338,2],[451,171],[326,87],[343,172],[454,173],[339,2],[342,174],[340,2],[452,175],[449,176],[448,2],[450,2],[346,2],[424,177],[211,178],[324,179],[328,180],[344,181],[347,182],[336,183],[329,184],[474,185],[402,186],[320,187],[208,188],[473,189],[204,190],[395,191],[387,2],[396,192],[413,193],[385,2],[412,194],[92,2],[407,195],[232,2],[427,196],[403,2],[217,2],[219,2],[358,2],[411,197],[235,2],[259,198],[345,199],[265,200],[325,2],[410,2],[389,2],[415,201],[416,202],[357,2],[418,203],[420,204],[419,205],[369,2],[409,188],[422,206],[319,207],[408,208],[414,209],[244,2],[248,2],[247,2],[246,2],[251,2],[245,2],[254,2],[253,2],[250,2],[249,2],[252,2],[255,210],[243,2],[311,211],[310,2],[315,212],[312,213],[314,214],[317,212],[313,213],[224,215],[303,216],[470,217],[468,2],[497,218],[499,219],[463,220],[498,221],[212,222],[209,222],[242,2],[226,223],[225,224],[221,225],[222,226],[230,227],[258,227],[269,227],[305,228],[270,228],[214,229],[213,2],[309,230],[308,231],[307,232],[306,233],[215,234],[456,235],[257,236],[462,237],[429,238],[458,239],[461,240],[352,241],[351,242],[332,243],[318,244],[300,245],[302,246],[299,247],[421,248],[323,2],[485,2],[220,249],[423,250],[469,251],[330,2],[260,252],[337,253],[335,254],[262,255],[397,256],[464,2],[263,257],[398,257],[483,2],[482,2],[484,2],[466,2],[465,2],[400,258],[327,2],[297,259],[218,260],[276,2],[202,261],[264,2],[489,87],[201,2],[501,262],[284,87],[495,23],[283,263],[478,264],[281,262],[206,2],[503,265],[279,87],[280,87],[271,2],[200,2],[278,266],[277,267],[266,268],[341,64],[401,64],[417,2],[405,269],[404,2],[289,139],[210,2],[298,87],[472,150],[479,270],[87,87],[90,271],[91,272],[88,87],[89,2],[378,273],[373,274],[372,2],[371,275],[370,2],[477,276],[488,277],[490,278],[494,279],[496,280],[500,281],[528,282],[504,282],[527,283],[506,284],[516,285],[517,286],[519,287],[523,288],[526,150],[525,2],[524,289],[548,290],[546,291],[547,292],[535,293],[536,291],[543,294],[534,295],[539,296],[549,2],[540,297],[545,298],[551,299],[550,300],[533,301],[541,302],[542,303],[537,304],[544,290],[538,305],[406,306],[532,2],[554,307],[553,2],[552,2],[555,308],[81,2],[82,2],[13,2],[14,2],[16,2],[15,2],[2,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[24,2],[3,2],[25,2],[26,2],[4,2],[27,2],[31,2],[28,2],[29,2],[30,2],[32,2],[33,2],[34,2],[5,2],[35,2],[36,2],[37,2],[38,2],[6,2],[42,2],[39,2],[40,2],[41,2],[43,2],[7,2],[44,2],[49,2],[50,2],[45,2],[46,2],[47,2],[48,2],[8,2],[54,2],[51,2],[52,2],[53,2],[55,2],[9,2],[56,2],[57,2],[58,2],[60,2],[59,2],[61,2],[62,2],[10,2],[63,2],[64,2],[65,2],[11,2],[66,2],[67,2],[68,2],[69,2],[70,2],[1,2],[71,2],[72,2],[12,2],[76,2],[74,2],[79,2],[78,2],[73,2],[77,2],[75,2],[80,2],[118,309],[128,310],[117,309],[138,311],[109,312],[108,313],[137,289],[131,314],[136,315],[111,316],[125,317],[110,318],[134,319],[106,320],[105,289],[135,321],[107,322],[112,323],[113,2],[116,323],[103,2],[139,324],[129,325],[120,326],[121,327],[123,328],[119,329],[122,330],[132,289],[114,331],[115,332],[124,333],[104,334],[127,325],[126,323],[130,2],[133,335],[556,336]],"affectedFilesPendingEmit":[607,578,580,579,576,584,582,585,581,577,587,586,590,589,591,588,593,594,592,596,600,602,601,597,572,604,605,573,574,583,603,571,575,559,558,560,563,562,561,595,598,599,564,565,570,531,556],"version":"5.9.3"} \ No newline at end of file