From dd03b5d01c6c67fcf497792e9100b860ca20b89b Mon Sep 17 00:00:00 2001 From: valgulnecron <39313199+ValgulNecron@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:55:44 +0200 Subject: [PATCH 1/2] chore: gitignore the local cosign signing key The module-signing private key lives at the repo root during a signing run. It was never committed, but nothing stopped an `git add -A` from doing so. Ignore it so it cannot be staged by accident. Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 48ae59d9c..9277f1e88 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ kubeconfig-* # Claude Code local state .claude/settings.local.json .claude/scheduled_tasks.lock +cosign.key From 120e132f7b8a4235f4d4d5fc6faae0854c9a377c Mon Sep 17 00:00:00 2001 From: valgulnecron <39313199+ValgulNecron@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:56:31 +0200 Subject: [PATCH 2/2] fix(registry): scope CurseForge browse to the template's own game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CurseForge provider hardcoded gameId=432 (Minecraft) on every search, so ARK's mods browser listed Minecraft mods. Verified against the live CurseForge API with the configured key: gameId 432 returns Minecraft content, gameId 83374 returns real ARK mods. 83374 is ARK: Survival Ascended's id from CurseForge's own /v1/games (slug ark-survival-ascended), not a guess. Templates now declare curseforgeGameID, required by CEL when provider is curseforge — there is no safe default, and defaulting is what produced the bug. classId filtering (6=mods, 4471=modpacks) stays gated on Minecraft, since CurseForge class ids are per-game taxonomy and applying Minecraft's to another game silently returns nothing. Refs: rule 7 (codegen committed with the type change). Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com> --- api/internal/handlers/registry.go | 25 ++++--- api/internal/handlers/registry_test.go | 35 ++++++++++ api/internal/registry/curseforge.go | 64 ++++++++++++++--- api/internal/registry/curseforge_test.go | 68 +++++++++++++++++-- api/internal/registry/keys_test.go | 2 +- api/internal/registry/registry.go | 20 ++++-- api/internal/registry/registry_test.go | 11 ++- .../crds/gameplane.local_gametemplates.yaml | 41 +++++++---- operator/api/v1alpha1/gametemplate_types.go | 37 ++++++---- .../api/v1alpha1/zz_generated.deepcopy.go | 5 ++ .../crd/gameplane.local_gametemplates.yaml | 41 +++++++---- web/src/types.ts | 4 ++ 12 files changed, 276 insertions(+), 77 deletions(-) diff --git a/api/internal/handlers/registry.go b/api/internal/handlers/registry.go index 362c211f9..42b9c280a 100644 --- a/api/internal/handlers/registry.go +++ b/api/internal/handlers/registry.go @@ -231,11 +231,12 @@ func (h *registryHandler) resolve(ctx context.Context, ns, name, providerName st return nil, "", "", errNoRegistry } p, ok := h.reg.For(ctx, registry.Config{ - Provider: prov.provider, - Community: prov.community, - SteamAppID: prov.steamAppID, - GitHubOwner: prov.githubOwner, - GitHubRepo: prov.githubRepo, + Provider: prov.provider, + Community: prov.community, + SteamAppID: prov.steamAppID, + CurseForgeGameID: prov.curseforgeGameID, + GitHubOwner: prov.githubOwner, + GitHubRepo: prov.githubRepo, }) if !ok { return nil, "", "", errNoRegistry @@ -254,12 +255,13 @@ func (h *registryHandler) writeResolveErr(w http.ResponseWriter, req *http.Reque } type providerCfg struct { - provider string - community string - steamAppID int32 - githubOwner string - githubRepo string - modpacks *modpackCfg + provider string + community string + steamAppID int32 + curseforgeGameID int32 + githubOwner string + githubRepo string + modpacks *modpackCfg } type modpackCfg struct { @@ -289,6 +291,7 @@ func registryProviders(tmpl *unstructured.Unstructured) []providerCfg { cfg := providerCfg{provider: name} cfg.community, _ = m["community"].(string) cfg.steamAppID = nestedInt32(m["steamAppID"]) + cfg.curseforgeGameID = nestedInt32(m["curseforgeGameID"]) if gh, ok := m["github"].(map[string]any); ok { cfg.githubOwner, _ = gh["owner"].(string) cfg.githubRepo, _ = gh["repo"].(string) diff --git a/api/internal/handlers/registry_test.go b/api/internal/handlers/registry_test.go index 6f8aa5df6..38f9bc61a 100644 --- a/api/internal/handlers/registry_test.go +++ b/api/internal/handlers/registry_test.go @@ -194,6 +194,41 @@ func TestRegistrySearch_ThreadsSteamAppID(t *testing.T) { } } +// TestRegistrySearch_ThreadsCurseForgeGameID is the handler-level regression +// guard for the "ARK's mod browser shows Minecraft mods" bug: +// registryProviders() must read a template's declared curseforgeGameID and +// thread it through to registry.Config, never a hardcoded Minecraft id — +// mirrors TestRegistrySearch_ThreadsSteamAppID above. +func TestRegistrySearch_ThreadsCurseForgeGameID(t *testing.T) { + versions := []any{map[string]any{"id": "v", "loader": "", "gameVersion": "", "default": true}} + for _, tc := range []struct { + name string + gameID any // matches whatever type an unstructured numeric field may decode as + wantVal int32 + }{ + {"int64", int64(83374), 83374}, + {"float64", float64(83374), 83374}, + {"json.Number", json.Number("83374"), 83374}, + } { + t.Run(tc.name, func(t *testing.T) { + k := fakeKubeClient( + newTemplateObj("ark", map[string]any{"provider": "curseforge", "curseforgeGameID": tc.gameID}, versions), + serverWithVersion("gameplane-games", "alpha", "ark", ""), + ) + var gotCfg registry.Config + r := mountRegistryRouter(k, fakeSet{p: &fakeProvider{}, cfgOut: &gotCfg}) + + rr := do(t, r, "GET", "/servers/alpha/mods/registry/search?q=ark-mod", nil) + if rr.Code != 200 { + t.Fatalf("got %d %s", rr.Code, rr.Body) + } + if gotCfg.Provider != "curseforge" || gotCfg.CurseForgeGameID != tc.wantVal { + t.Errorf("cfg = %+v, want provider=curseforge curseforgeGameID=%d (not the hardcoded Minecraft 432)", gotCfg, tc.wantVal) + } + }) + } +} + // TestNestedInt32 covers every numeric shape nestedInt32 accepts, including // the bare int case (a defensive branch for a hand-built test fixture; a // real unstructured decode never produces it) that TestRegistrySearch_ diff --git a/api/internal/registry/curseforge.go b/api/internal/registry/curseforge.go index bd96cccc9..460aa5fd5 100644 --- a/api/internal/registry/curseforge.go +++ b/api/internal/registry/curseforge.go @@ -11,9 +11,12 @@ import ( ) // Curseforge searches the CurseForge v1 API (api.curseforge.com), which -// requires an x-api-key. Suits Minecraft mods + modpacks. Loader filtering -// maps Gameplane loader ids to CurseForge's numeric modLoaderType; the clean -// game-version token is the gameVersion filter. +// requires an x-api-key. CurseForge hosts mods for many games, not just +// Minecraft — Search must be faceted to exactly one numeric CurseForge game +// id (see curseforgeGame below — a template picks the game the way +// Thunderstore picks a community or Steam picks an app id). Loader +// filtering maps Gameplane loader ids to CurseForge's numeric +// modLoaderType; the clean game-version token is the gameVersion filter. type Curseforge struct { client *http.Client userAgent string @@ -25,7 +28,32 @@ func newCurseforge(client *http.Client, userAgent, apiKey string) *Curseforge { return &Curseforge{client: client, userAgent: userAgent, apiKey: apiKey, baseURL: "https://api.curseforge.com/v1"} } +// curseforgeGame binds the CurseForge engine to one template's numeric +// CurseForge game id, mirroring steamApp's per-config wrapper. CurseForge +// has no single "browse everything" search — a template must declare +// curseforgeGameID the same way it declares steamAppID. +type curseforgeGame struct { + cf *Curseforge + gameID int32 +} + +func (g *curseforgeGame) Search(ctx context.Context, q SearchQuery) ([]Project, error) { + return g.cf.search(ctx, q, g.gameID) +} + +func (g *curseforgeGame) Versions(ctx context.Context, projectID string, f Filter) ([]Version, error) { + return g.cf.Versions(ctx, projectID, f) +} + +// ModpackDeps is a no-op: the games wired to CurseForge install content as +// single mods (or via idList), not via dependency resolution. +func (g *curseforgeGame) ModpackDeps(ctx context.Context, projectID string) ([]File, error) { + return g.cf.ModpackDeps(ctx, projectID) +} + // CurseForge numeric constants (stable, from the official API schema). +// cfGameMinecraft also marks the one game whose classId taxonomy this +// package knows — see classID below. const ( cfGameMinecraft = 432 cfClassMods = 6 @@ -64,18 +92,32 @@ func cfSortField(sort string) int { } } -func (c *Curseforge) classID(q SearchQuery) int { +// classID returns the CurseForge classId to filter results by, or 0 for "no +// class filter". CurseForge's class ids are per-game — 6 (mods) and 4471 +// (modpacks) are Minecraft's ids specifically, not a general CurseForge +// concept. Sending them for a different game would filter by classes that +// don't exist there and silently return zero results, so that split is +// kept ONLY for gameId 432 (Minecraft); every other game gets no classId +// filter, i.e. its search isn't narrowed to mods vs. modpacks. +func (c *Curseforge) classID(q SearchQuery, gameID int32) int { + if gameID != cfGameMinecraft { + return 0 + } if q.modpack() { return cfClassModpacks } return cfClassMods } -// Search runs a CurseForge mod/modpack search scoped to Minecraft. -func (c *Curseforge) Search(ctx context.Context, q SearchQuery) ([]Project, error) { +// search runs a CurseForge mod/modpack search scoped to gameID (see +// curseforgeGame — the exported Provider surface is that per-config +// wrapper, not this method). +func (c *Curseforge) search(ctx context.Context, q SearchQuery, gameID int32) ([]Project, error) { params := url.Values{} - params.Set("gameId", strconv.Itoa(cfGameMinecraft)) - params.Set("classId", strconv.Itoa(c.classID(q))) + params.Set("gameId", strconv.Itoa(int(gameID))) + if classID := c.classID(q, gameID); classID != 0 { + params.Set("classId", strconv.Itoa(classID)) + } if q.Term != "" { params.Set("searchFilter", q.Term) } @@ -194,8 +236,10 @@ type cfFile struct { GameVersions []string `json:"gameVersions"` } -// ModpackDeps is a no-op: the Minecraft modules install CurseForge content -// as single mods, not via dependency resolution. +// ModpackDeps is a no-op: the games wired to CurseForge install content as +// single mods (or via idList), not via dependency resolution — see +// curseforgeGame.ModpackDeps, the Provider surface that actually gets +// called. func (c *Curseforge) ModpackDeps(_ context.Context, _ string) ([]File, error) { return nil, nil } diff --git a/api/internal/registry/curseforge_test.go b/api/internal/registry/curseforge_test.go index 5393dd61b..5fa6bcd59 100644 --- a/api/internal/registry/curseforge_test.go +++ b/api/internal/registry/curseforge_test.go @@ -14,13 +14,21 @@ func testCurseforge(url string) *Curseforge { return c } +// testCurseforgeGame wraps testCurseforge with a configured game id, +// mirroring testSteamApp — the way tests exercise the Provider surface a +// template's registry config actually resolves to. +func testCurseforgeGame(url string, gameID int32) *curseforgeGame { + return &curseforgeGame{cf: testCurseforge(url), gameID: gameID} +} + func TestCurseforgeSearch(t *testing.T) { - var gotKey, gotClass, gotLoader, gotSort, gotFilter, gotIndex, gotGV string + var gotKey, gotGameID, gotClass, gotLoader, gotSort, gotFilter, gotIndex, gotGV string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/mods/search" { t.Errorf("path = %q, want /mods/search", r.URL.Path) } gotKey = r.Header.Get("x-api-key") + gotGameID = r.URL.Query().Get("gameId") gotClass = r.URL.Query().Get("classId") gotLoader = r.URL.Query().Get("modLoaderType") gotSort = r.URL.Query().Get("sortField") @@ -33,7 +41,7 @@ func TestCurseforgeSearch(t *testing.T) { })) defer srv.Close() - got, err := testCurseforge(srv.URL).Search(context.Background(), SearchQuery{ + got, err := testCurseforgeGame(srv.URL, cfGameMinecraft).Search(context.Background(), SearchQuery{ Term: "jei", Loader: "fabric", GameVersion: "1.21.4", Sort: "downloads", Offset: 50, Limit: 20, }) if err != nil { @@ -42,6 +50,9 @@ func TestCurseforgeSearch(t *testing.T) { if gotKey != "secret-key" { t.Errorf("x-api-key = %q", gotKey) } + if gotGameID != "432" { + t.Errorf("gameId = %q, want 432 (minecraft)", gotGameID) + } if gotClass != "6" { t.Errorf("classId = %q, want 6 (mods)", gotClass) } @@ -70,7 +81,7 @@ func TestCurseforgeModpackClass(t *testing.T) { _, _ = w.Write([]byte(`{"data":[]}`)) })) defer srv.Close() - if _, err := testCurseforge(srv.URL).Search(context.Background(), SearchQuery{ProjectType: "modpack", Limit: 20}); err != nil { + if _, err := testCurseforgeGame(srv.URL, cfGameMinecraft).Search(context.Background(), SearchQuery{ProjectType: "modpack", Limit: 20}); err != nil { t.Fatalf("Search: %v", err) } if gotClass != "4471" { @@ -78,6 +89,34 @@ func TestCurseforgeModpackClass(t *testing.T) { } } +// TestCurseforgeSearchUsesConfiguredGameID is the regression guard for the +// "ARK's mod browser shows Minecraft mods" bug: the game id sent upstream +// must be the one configured on the template's provider (curseforgeGameID), +// never the hardcoded Minecraft id. It also proves a non-Minecraft game +// gets no classId filter, since CurseForge's mod/modpack class ids (6, +// 4471) are Minecraft-specific and would silently zero out another game's +// results. +func TestCurseforgeSearchUsesConfiguredGameID(t *testing.T) { + const arkGameID = 83374 // ARK: Survival Ascended, from CurseForge's /v1/games. + var gotGameID, gotClass string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotGameID = r.URL.Query().Get("gameId") + gotClass = r.URL.Query().Get("classId") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + if _, err := testCurseforgeGame(srv.URL, arkGameID).Search(context.Background(), SearchQuery{Term: "s+"}); err != nil { + t.Fatalf("Search: %v", err) + } + if gotGameID != "83374" { + t.Errorf("gameId = %q, want 83374 (ARK: Survival Ascended), not the hardcoded Minecraft id", gotGameID) + } + if gotClass != "" { + t.Errorf("classId = %q, want unset for a non-Minecraft game", gotClass) + } +} + func TestCurseforgeVersionsSkipsUnavailable(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/mods/238222/files" { @@ -109,12 +148,33 @@ func TestCurseforgeModpackDepsNil(t *testing.T) { } } +// TestCurseforgeGameDelegates proves curseforgeGame's Versions and +// ModpackDeps just forward to the underlying engine (game-agnostic, unlike +// Search) — mirroring steamApp's equivalent wrapper-delegation coverage. +func TestCurseforgeGameDelegates(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/mods/238222/files" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + game := testCurseforgeGame(srv.URL, 83374) + if _, err := game.Versions(context.Background(), "238222", Filter{}); err != nil { + t.Fatalf("Versions: %v", err) + } + if files, err := game.ModpackDeps(context.Background(), "238222"); err != nil || files != nil { + t.Fatalf("ModpackDeps = %v, %v; want nil, nil", files, err) + } +} + func TestCurseforgeUpstreamError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer srv.Close() - if _, err := testCurseforge(srv.URL).Search(context.Background(), SearchQuery{Term: "x"}); err == nil { + if _, err := testCurseforgeGame(srv.URL, cfGameMinecraft).Search(context.Background(), SearchQuery{Term: "x"}); err == nil { t.Fatal("expected error on 403") } } diff --git a/api/internal/registry/keys_test.go b/api/internal/registry/keys_test.go index 4d3ee54ee..252ed847e 100644 --- a/api/internal/registry/keys_test.go +++ b/api/internal/registry/keys_test.go @@ -169,7 +169,7 @@ func TestSet_AvailableTogglesWithSecret(t *testing.T) { if !set.Available(ctx, "curseforge") { t.Fatal("expected curseforge available once the key secret exists") } - if _, ok := set.For(ctx, Config{Provider: "curseforge"}); !ok { + if _, ok := set.For(ctx, Config{Provider: "curseforge", CurseForgeGameID: 432}); !ok { t.Fatal("expected For to resolve curseforge once the key secret exists") } diff --git a/api/internal/registry/registry.go b/api/internal/registry/registry.go index e5501c3e4..0a865e5eb 100644 --- a/api/internal/registry/registry.go +++ b/api/internal/registry/registry.go @@ -138,6 +138,10 @@ type Config struct { // SteamAppID facets Steam Workshop browse/search to one app. Required // by the steam provider; ignored by others. SteamAppID int32 + // CurseForgeGameID facets CurseForge browse/search to one numeric + // CurseForge game (e.g. 432 for Minecraft, 83374 for ARK: Survival + // Ascended). Required by the curseforge provider; ignored by others. + CurseForgeGameID int32 // GitHubOwner and GitHubRepo bind the "github" provider to one // repository's Releases — GitHub has no cross-repo mod search, so // (unlike every keyless engine above) a template must pick exactly one @@ -221,10 +225,11 @@ func NewSet(version string, keyFunc KeyFunc) *Set { // For returns the provider for a module's registry config. ok is false // when the provider is unknown/unset, a required parameter is missing -// (Thunderstore/Nexus need a community/domain, Steam needs an app id), or -// the engine isn't configured (a keyed provider without a key) — the -// handler maps that to 501 so the dashboard hides the provider. For keyed -// providers, the key is resolved lazily via context and cached with a TTL. +// (Thunderstore/Nexus need a community/domain, Steam needs an app id, +// CurseForge needs a game id), or the engine isn't configured (a keyed +// provider without a key) — the handler maps that to 501 so the dashboard +// hides the provider. For keyed providers, the key is resolved lazily via +// context and cached with a TTL. func (s *Set) For(ctx context.Context, cfg Config) (Provider, bool) { switch cfg.Provider { case "modrinth": @@ -235,11 +240,14 @@ func (s *Set) For(ctx context.Context, cfg Config) (Provider, bool) { } return &thunderstoreCommunity{ts: s.thunderstore, community: cfg.Community}, true case "curseforge": + if cfg.CurseForgeGameID <= 0 { + return nil, false + } cf, err := s.curseforgeLazy(ctx) if err != nil || cf == nil { return nil, false } - return cf, true + return &curseforgeGame{cf: cf, gameID: cfg.CurseForgeGameID}, true case "hangar": return s.hangar, true case "factorio": @@ -280,7 +288,7 @@ func (s *Set) For(ctx context.Context, cfg Config) (Provider, bool) { // specific server — the providers listing uses it to mark the dashboard's // provider switch. Keyed providers' availability depends on whether an API // key is currently configured; it does not check per-server parameters -// (SteamAppID/Community), which For validates. +// (SteamAppID/CurseForgeGameID/Community), which For validates. func (s *Set) Available(ctx context.Context, provider string) bool { switch provider { case "modrinth", "thunderstore", "hangar", "factorio", "spigot", "github", "umod": diff --git a/api/internal/registry/registry_test.go b/api/internal/registry/registry_test.go index 875faf486..0fc0be7b7 100644 --- a/api/internal/registry/registry_test.go +++ b/api/internal/registry/registry_test.go @@ -48,7 +48,7 @@ func TestSetFor(t *testing.T) { t.Error("github without owner should not be selectable") } // CurseForge is key-gated: not selectable without a key. - if _, ok := s.For(ctx, Config{Provider: "curseforge"}); ok { + if _, ok := s.For(ctx, Config{Provider: "curseforge", CurseForgeGameID: 432}); ok { t.Error("curseforge without a key should not be selectable") } if _, ok := s.For(ctx, Config{Provider: "nope"}); ok { @@ -78,8 +78,13 @@ func TestSetAvailable(t *testing.T) { if !withKey.Available(ctx, "curseforge") { t.Error("curseforge should be available with a key") } - if p, ok := withKey.For(ctx, Config{Provider: "curseforge"}); !ok || p == nil { - t.Errorf("curseforge with key: ok=%v p=%v", ok, p) + if p, ok := withKey.For(ctx, Config{Provider: "curseforge", CurseForgeGameID: 432}); !ok || p == nil { + t.Errorf("curseforge with key and gameID: ok=%v p=%v", ok, p) + } + // CurseForge is also gameID-gated: a key alone isn't enough, the same + // way Steam needs both a key and an appID. + if _, ok := withKey.For(ctx, Config{Provider: "curseforge"}); ok { + t.Error("curseforge without a curseforgeGameID should not be selectable") } } diff --git a/charts/gameplane/crds/gameplane.local_gametemplates.yaml b/charts/gameplane/crds/gameplane.local_gametemplates.yaml index 748efa708..7aa04dd3b 100644 --- a/charts/gameplane/crds/gameplane.local_gametemplates.yaml +++ b/charts/gameplane/crds/gameplane.local_gametemplates.yaml @@ -510,6 +510,15 @@ spec: required: - name type: object + curseforgeGameID: + description: |- + CurseForgeGameID is the numeric CurseForge game the browser searches + (Minecraft is 432; ARK: Survival Ascended is 83374 — from CurseForge's + own /v1/games). Required when provider is curseforge: the API has no + safe default, and guessing one is how ARK's browser ended up listing + Minecraft mods. + format: int32 + type: integer github: description: |- GitHub binds this provider to one repository's Releases. GitHub has @@ -733,20 +742,20 @@ spec: description: |- Provider names the built-in registry engine: "modrinth" (Minecraft mods/plugins, keyless), "thunderstore" (BepInEx games, keyless, - per-community), "curseforge" (Minecraft mods/modpacks, needs an API - key), "hangar" (PaperMC plugins, keyless), "factorio" (the official - Factorio mod portal; browse is keyless, downloads need the player's - own factorio.com credentials so installs hand off to the from-URL - form), "steam" (Steam Workshop browse, needs a Steam Web API key; - see SteamAppID — Workshop content has no download URL, so it's a - preview-only browser wired to modpacks.refEnv for collection-based - games like Garry's Mod/CS2), "nexus" (Nexus Mods, needs an API key, - browse-only for the same reason as steam — see Community for its - per-game domain slug), "spigot" (SpigotMC plugins via the Spiget API, - keyless), "github" (one repository's Releases stand in for - versions, keyless but rate-limited — see GitHub), or "umod" - (Rust/Hurtworld/7 Days to Die's Oxide/uMod plugin ecosystem, - keyless). + per-community), "curseforge" (mods/modpacks for the game identified + by CurseForgeGameID, needs an API key), "hangar" (PaperMC plugins, + keyless), "factorio" (the official Factorio mod portal; browse is + keyless, downloads need the player's own factorio.com credentials so + installs hand off to the from-URL form), "steam" (Steam Workshop + browse, needs a Steam Web API key; see SteamAppID — Workshop content + has no download URL, so it's a preview-only browser wired to + modpacks.refEnv for collection-based games like Garry's Mod/CS2), + "nexus" (Nexus Mods, needs an API key, browse-only for the same + reason as steam — see Community for its per-game domain slug), + "spigot" (SpigotMC plugins via the Spiget API, keyless), "github" + (one repository's Releases stand in for versions, keyless but + rate-limited — see GitHub), or "umod" (Rust/Hurtworld/7 Days to + Die's Oxide/uMod plugin ecosystem, keyless). enum: - modrinth - thunderstore @@ -777,6 +786,10 @@ spec: && self.steamAppID > 0) - message: github is required when provider is github rule: self.provider != 'github' || has(self.github) + - message: curseforgeGameID is required when provider + is curseforge + rule: self.provider != 'curseforge' || (has(self.curseforgeGameID) + && self.curseforgeGameID > 0) maxItems: 8 minItems: 1 type: array diff --git a/operator/api/v1alpha1/gametemplate_types.go b/operator/api/v1alpha1/gametemplate_types.go index 66242c7c4..26f7a5783 100644 --- a/operator/api/v1alpha1/gametemplate_types.go +++ b/operator/api/v1alpha1/gametemplate_types.go @@ -432,23 +432,24 @@ type ModRegistrySpec struct { // // +kubebuilder:validation:XValidation:rule="self.provider != 'steam' || (has(self.steamAppID) && self.steamAppID > 0)",message="steamAppID is required when provider is steam" // +kubebuilder:validation:XValidation:rule="self.provider != 'github' || has(self.github)",message="github is required when provider is github" +// +kubebuilder:validation:XValidation:rule="self.provider != 'curseforge' || (has(self.curseforgeGameID) && self.curseforgeGameID > 0)",message="curseforgeGameID is required when provider is curseforge" type ModProvider struct { // Provider names the built-in registry engine: "modrinth" (Minecraft // mods/plugins, keyless), "thunderstore" (BepInEx games, keyless, - // per-community), "curseforge" (Minecraft mods/modpacks, needs an API - // key), "hangar" (PaperMC plugins, keyless), "factorio" (the official - // Factorio mod portal; browse is keyless, downloads need the player's - // own factorio.com credentials so installs hand off to the from-URL - // form), "steam" (Steam Workshop browse, needs a Steam Web API key; - // see SteamAppID — Workshop content has no download URL, so it's a - // preview-only browser wired to modpacks.refEnv for collection-based - // games like Garry's Mod/CS2), "nexus" (Nexus Mods, needs an API key, - // browse-only for the same reason as steam — see Community for its - // per-game domain slug), "spigot" (SpigotMC plugins via the Spiget API, - // keyless), "github" (one repository's Releases stand in for - // versions, keyless but rate-limited — see GitHub), or "umod" - // (Rust/Hurtworld/7 Days to Die's Oxide/uMod plugin ecosystem, - // keyless). + // per-community), "curseforge" (mods/modpacks for the game identified + // by CurseForgeGameID, needs an API key), "hangar" (PaperMC plugins, + // keyless), "factorio" (the official Factorio mod portal; browse is + // keyless, downloads need the player's own factorio.com credentials so + // installs hand off to the from-URL form), "steam" (Steam Workshop + // browse, needs a Steam Web API key; see SteamAppID — Workshop content + // has no download URL, so it's a preview-only browser wired to + // modpacks.refEnv for collection-based games like Garry's Mod/CS2), + // "nexus" (Nexus Mods, needs an API key, browse-only for the same + // reason as steam — see Community for its per-game domain slug), + // "spigot" (SpigotMC plugins via the Spiget API, keyless), "github" + // (one repository's Releases stand in for versions, keyless but + // rate-limited — see GitHub), or "umod" (Rust/Hurtworld/7 Days to + // Die's Oxide/uMod plugin ecosystem, keyless). // +kubebuilder:validation:Enum=modrinth;thunderstore;curseforge;hangar;factorio;steam;spigot;github;umod;nexus Provider string `json:"provider"` @@ -468,6 +469,14 @@ type ModProvider struct { // +optional SteamAppID int32 `json:"steamAppID,omitempty"` + // CurseForgeGameID is the numeric CurseForge game the browser searches + // (Minecraft is 432; ARK: Survival Ascended is 83374 — from CurseForge's + // own /v1/games). Required when provider is curseforge: the API has no + // safe default, and guessing one is how ARK's browser ended up listing + // Minecraft mods. + // +optional + CurseForgeGameID *int32 `json:"curseforgeGameID,omitempty"` + // GitHub binds this provider to one repository's Releases. GitHub has // no cross-repo mod search (unlike Thunderstore's per-community // index), so a template picks exactly one repo to browse. Required diff --git a/operator/api/v1alpha1/zz_generated.deepcopy.go b/operator/api/v1alpha1/zz_generated.deepcopy.go index fcede719c..c748055b2 100644 --- a/operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/operator/api/v1alpha1/zz_generated.deepcopy.go @@ -1390,6 +1390,11 @@ func (in *ModLoaderSpec) DeepCopy() *ModLoaderSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ModProvider) DeepCopyInto(out *ModProvider) { *out = *in + if in.CurseForgeGameID != nil { + in, out := &in.CurseForgeGameID, &out.CurseForgeGameID + *out = new(int32) + **out = **in + } if in.GitHub != nil { in, out := &in.GitHub, &out.GitHub *out = new(GitHubRepoSpec) diff --git a/operator/config/crd/gameplane.local_gametemplates.yaml b/operator/config/crd/gameplane.local_gametemplates.yaml index 748efa708..7aa04dd3b 100644 --- a/operator/config/crd/gameplane.local_gametemplates.yaml +++ b/operator/config/crd/gameplane.local_gametemplates.yaml @@ -510,6 +510,15 @@ spec: required: - name type: object + curseforgeGameID: + description: |- + CurseForgeGameID is the numeric CurseForge game the browser searches + (Minecraft is 432; ARK: Survival Ascended is 83374 — from CurseForge's + own /v1/games). Required when provider is curseforge: the API has no + safe default, and guessing one is how ARK's browser ended up listing + Minecraft mods. + format: int32 + type: integer github: description: |- GitHub binds this provider to one repository's Releases. GitHub has @@ -733,20 +742,20 @@ spec: description: |- Provider names the built-in registry engine: "modrinth" (Minecraft mods/plugins, keyless), "thunderstore" (BepInEx games, keyless, - per-community), "curseforge" (Minecraft mods/modpacks, needs an API - key), "hangar" (PaperMC plugins, keyless), "factorio" (the official - Factorio mod portal; browse is keyless, downloads need the player's - own factorio.com credentials so installs hand off to the from-URL - form), "steam" (Steam Workshop browse, needs a Steam Web API key; - see SteamAppID — Workshop content has no download URL, so it's a - preview-only browser wired to modpacks.refEnv for collection-based - games like Garry's Mod/CS2), "nexus" (Nexus Mods, needs an API key, - browse-only for the same reason as steam — see Community for its - per-game domain slug), "spigot" (SpigotMC plugins via the Spiget API, - keyless), "github" (one repository's Releases stand in for - versions, keyless but rate-limited — see GitHub), or "umod" - (Rust/Hurtworld/7 Days to Die's Oxide/uMod plugin ecosystem, - keyless). + per-community), "curseforge" (mods/modpacks for the game identified + by CurseForgeGameID, needs an API key), "hangar" (PaperMC plugins, + keyless), "factorio" (the official Factorio mod portal; browse is + keyless, downloads need the player's own factorio.com credentials so + installs hand off to the from-URL form), "steam" (Steam Workshop + browse, needs a Steam Web API key; see SteamAppID — Workshop content + has no download URL, so it's a preview-only browser wired to + modpacks.refEnv for collection-based games like Garry's Mod/CS2), + "nexus" (Nexus Mods, needs an API key, browse-only for the same + reason as steam — see Community for its per-game domain slug), + "spigot" (SpigotMC plugins via the Spiget API, keyless), "github" + (one repository's Releases stand in for versions, keyless but + rate-limited — see GitHub), or "umod" (Rust/Hurtworld/7 Days to + Die's Oxide/uMod plugin ecosystem, keyless). enum: - modrinth - thunderstore @@ -777,6 +786,10 @@ spec: && self.steamAppID > 0) - message: github is required when provider is github rule: self.provider != 'github' || has(self.github) + - message: curseforgeGameID is required when provider + is curseforge + rule: self.provider != 'curseforge' || (has(self.curseforgeGameID) + && self.curseforgeGameID > 0) maxItems: 8 minItems: 1 type: array diff --git a/web/src/types.ts b/web/src/types.ts index 5f2b52904..9d56c62bc 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -91,6 +91,10 @@ export interface ModProviderDecl { // Required (>0) when provider is "steam" — facets Workshop browse/search // to this Steam app id. Ignored otherwise. steamAppID?: number; + // Required (>0) when provider is "curseforge" — the numeric CurseForge + // game this provider searches (e.g. 432 for Minecraft, 83374 for ARK: + // Survival Ascended). Ignored otherwise. + curseforgeGameID?: number; // Required when provider is "github" — the one repository whose Releases // stand in for versions. Ignored otherwise. github?: { owner: string; repo: string };