From f0874eb3b185160ec9873bf1fb6486c28b73dd09 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Sun, 16 Aug 2026 23:15:56 +0200 Subject: [PATCH] feat: asobi retention, set how long an environment keeps unclaimed guests Unclaimed guests are accounts nobody ever signed in to claim, and nothing removes them unless the environment says how long to keep them, so a game minting a guest per device accumulates them for ever. That was settable only from the dashboard, while `asobi resize` has always written the neighbouring per-environment value from here. --after is required and has no default. Retention deletes player accounts permanently and the server's sweep writes no audit row, so the period is spelled out on every invocation rather than inherited from somewhere. Validated locally against the same list the control plane accepts, which saves a round trip on a typo; the server still checks, so this is a convenience and not the guard. `1` is not on the list - a one-day retention would erase a working game's whole guest population overnight. A 403 is reported as the role refusal it is rather than as a failed call, because the generic message sends somebody off to re-run `asobi login` to fix a permission they do not have. --- README.md | 1 + cmd/asobi/main.go | 37 ++++++++++ internal/auth/retention_test.go | 123 ++++++++++++++++++++++++++++++++ internal/auth/saas.go | 44 ++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 internal/auth/retention_test.go diff --git a/README.md b/README.md index c54b1b6..8b08396 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ asobi deploy prod game/ | `asobi stop [--game ]` | Stop a running environment | | `asobi start [--game ]` | Start a stopped environment | | `asobi resize --size [--game ]` | Resize an environment | +| `asobi retention --after [--game ]` | How long the environment keeps unclaimed guest accounts. Owner or admin only | | `asobi delete [--game ]` | Delete an environment | | `asobi destroy ` | Delete by env_id and revoke its keys (idempotent; used by CI cleanup) | | `asobi envs [--game ]` | List your environments | diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 720ee43..2001049 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "slices" "strconv" "strings" "sync" @@ -56,6 +57,8 @@ func main() { cmdStart() case "resize": cmdResize() + case "retention": + cmdRetention() case "delete": cmdDelete() case "envs": @@ -108,6 +111,9 @@ Usage: asobi stop [--game ] Stop an environment asobi start [--game ] Start an environment asobi resize --size [--game ] Resize an environment + asobi retention --after [--game ] + Keep unclaimed guests for ever, or delete them + after N days without a sign-in. Owner/admin only asobi delete [--game ] Delete an environment asobi envs [--game ] List your environments asobi health [env] [--game ] Check engine health (of an environment) @@ -615,6 +621,37 @@ func cmdResize() { fmt.Printf("Environment %s resizing to %s\n", args[0], sizeFlag) } +// Unclaimed guests are accounts nobody ever signed in to claim. Nothing +// removes them unless an environment says how long to keep them, so a game +// that mints a guest per device accumulates them for ever. +// +// --after is required and has no default. Retention deletes player accounts +// permanently and the sweep writes no audit row, so it is spelled out on every +// invocation rather than inherited from somewhere. +func cmdRetention() { + gameFlag, args := extractFlag(os.Args[2:], "--game") + afterFlag, args := extractFlag(args, "--after") + if len(args) < 1 { + fatal("usage: asobi retention --after [--game ]", strings.Join(auth.RetentionPeriods[1:], "|")) + } + if afterFlag == "" { + fatal("retention requires --after ", strings.Join(auth.RetentionPeriods[1:], "|")) + } + if !slices.Contains(auth.RetentionPeriods, afterFlag) { + fatal("unknown retention period %q - accepted: %s", afterFlag, strings.Join(auth.RetentionPeriods, ", ")) + } + creds := mustLoadCreds() + game := resolveGame(gameFlag, creds) + if err := auth.SetRetention(creds, game, args[0], afterFlag); err != nil { + fatal("retention: %v", err) + } + if afterFlag == "never" { + fmt.Printf("Environment %s keeps unclaimed guests for ever\n", args[0]) + return + } + fmt.Printf("Environment %s deletes unclaimed guests after %s days of inactivity - it restarts to pick this up\n", args[0], afterFlag) +} + func cmdDelete() { gameFlag, args := extractFlag(os.Args[2:], "--game") if len(args) < 1 { diff --git a/internal/auth/retention_test.go b/internal/auth/retention_test.go new file mode 100644 index 0000000..419020a --- /dev/null +++ b/internal/auth/retention_test.go @@ -0,0 +1,123 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSetRetentionSendsThePeriod(t *testing.T) { + var got map[string]string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer at-1" { + w.WriteHeader(401) + return + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &got) + w.WriteHeader(200) + w.Write([]byte(`{"retention":"Deleting unclaimed guests after 30 days of inactivity"}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + if err := SetRetention(creds, "", "prod", "30"); err != nil { + t.Fatalf("SetRetention: %v", err) + } + if got["after"] != "30" { + t.Fatalf(`after = %q, want "30"`, got["after"]) + } +} + +// "never" has to reach the server as a value rather than as an omitted field, +// or turning retention back off would read as a request that named no period +// and be refused. +func TestSetRetentionSendsNeverExplicitly(t *testing.T) { + var got map[string]string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &got) + w.WriteHeader(200) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + if err := SetRetention(creds, "", "prod", "never"); err != nil { + t.Fatalf("SetRetention: %v", err) + } + if _, ok := got["after"]; !ok { + t.Fatal("after was omitted; it must be sent") + } + if got["after"] != "never" { + t.Fatalf(`after = %q, want "never"`, got["after"]) + } +} + +// A role refusal is not an expired session. Reporting it as one sends somebody +// off to re-run `asobi login` to fix a permission they do not have. +func TestSetRetentionReportsRoleRefusalPlainly(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(403) + w.Write([]byte(`{"error":"requires_owner_or_admin"}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + err := SetRetention(creds, "", "prod", "30") + if err == nil { + t.Fatal("want an error") + } + if !strings.Contains(err.Error(), "owner or admin") { + t.Fatalf("error = %q, want it to name the role requirement", err) + } +} + +func TestSetRetentionPassesTheGame(t *testing.T) { + var gotQuery string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.WriteHeader(200) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + if err := SetRetention(creds, "arena", "prod", "90"); err != nil { + t.Fatalf("SetRetention: %v", err) + } + if !strings.Contains(gotQuery, "arena") { + t.Fatalf("query = %q, want it to carry the game slug", gotQuery) + } +} + +// The list the CLI checks against locally must be the one the server accepts. +// `1` is the value that matters: a one-day retention would erase a working +// game's whole guest population overnight. +func TestRetentionPeriodsAreTheOfferedSet(t *testing.T) { + want := []string{"never", "7", "30", "90", "365"} + if len(RetentionPeriods) != len(want) { + t.Fatalf("RetentionPeriods = %v, want %v", RetentionPeriods, want) + } + for i, v := range want { + if RetentionPeriods[i] != v { + t.Fatalf("RetentionPeriods[%d] = %q, want %q", i, RetentionPeriods[i], v) + } + } + for _, bad := range []string{"1", "0", "-30", "31", "forever", ""} { + for _, ok := range RetentionPeriods { + if bad == ok { + t.Fatalf("%q must not be an offered period", bad) + } + } + } +} diff --git a/internal/auth/saas.go b/internal/auth/saas.go index 52d2fad..9121975 100644 --- a/internal/auth/saas.go +++ b/internal/auth/saas.go @@ -268,6 +268,50 @@ func ResizeEnv(creds *Credentials, game, name, size string) error { return nil } +// RetentionPeriods are the values the control plane accepts for guest +// retention, in the order the help text lists them. Kept here so a typo is a +// local error rather than a round trip, but the server validates against its +// own list regardless - this is a convenience, not the check. +var RetentionPeriods = []string{"never", "7", "30", "90", "365"} + +// SetRetention sets how long an environment keeps unclaimed guest accounts. +// "never" keeps them for ever; a number is days of inactivity since the device +// last resumed the guest. Requires an owner or admin, and rolls the +// environment, because the engine reads the policy at boot. +func SetRetention(creds *Credentials, game, name, after string) error { + body, _ := json.Marshal(map[string]string{"after": after}) + req, err := http.NewRequest("POST", creds.SaasURL+"/internal/cli/envs/"+name+"/retention"+gameQuery(game), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return SetRetention(creds, game, name, after) + } + // 403 here is a role refusal, not an expired session, and saying so saves + // somebody re-running `asobi login` to fix a permission they do not have. + if resp.StatusCode == 403 { + return fmt.Errorf("only an owner or admin can change guest retention") + } + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("retention failed (%d): %s", resp.StatusCode, data) + } + return nil +} + // DeleteEnv deletes a named environment within a game. func DeleteEnv(creds *Credentials, game, name string) error { req, err := http.NewRequest("DELETE", creds.SaasURL+"/internal/cli/envs/"+name+gameQuery(game), nil)