diff --git a/README.md b/README.md index 8cf6881..292c54e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ CI

-Palhelm is a self-hosted web admin panel for Palworld dedicated servers. It is one Docker image and one process with no external database. It talks to your server three ways: the official REST API, RCON, and the world save file itself, which it parses directly (the 1.0 Oodle-compressed format) with a pure-Go parser. You get a live dashboard, player and pal data, a map, safe backups and restores, and a config editor that edits the thing your server actually reads. +Palhelm is a self-hosted web admin panel for Palworld dedicated servers. It is one Docker image and one process with no external database. Its three core channels are the official REST API, RCON, and the world save file itself, which it parses directly (the 1.0 Oodle-compressed format) with a pure-Go parser. An optional, separately installed UE4SS bridge can perform tightly bounded item grants after exact-build validation. You get a live dashboard, player and pal data, a map, safe backups and restores, and a config editor that edits the thing your server actually reads. Full documentation lives at [docs.palhelm.com](https://docs.palhelm.com). The showcase site is [palhelm.com](https://palhelm.com). A companion Discord bot lives at [github.com/8tp/palhelm-bot](https://github.com/8tp/palhelm-bot). @@ -19,6 +19,7 @@ Full documentation lives at [docs.palhelm.com](https://docs.palhelm.com). The sh - **Live dashboard.** Server FPS and frame-time history with charts, players-online history, per-channel health (REST, RCON, save sync), and an event feed. - **Players and Pals.** Online and offline players merged from the live API and save data. Kick, ban, unban. Inspect per-player parties and Palboxes or search the server-wide Pal explorer by owner, placement, level, and Alpha/Lucky/Boss status. Expand a Pal for individual save stats plus version-pinned numeric work-suitability badges. +- **Optional item grants.** Admins can queue bounded, allowlisted item grants for an online player through a separate UE4SS server bridge. The provider is disabled by default, audited, never edits saves, and stays unavailable until its catalogue and exact game build are validated. - **Command palette.** Players, actions, navigation, and saved RCON commands from one keystroke. Destructive entries are hidden from read-only viewers. - **Console.** A real RCON session with history, saved commands, and an honest note about what vanilla RCON cannot do. - **Live map.** Player and base markers on Palworld 1.0 tiles with Palpagos and World Tree layers. The optional Game Data capability adds ready-only PalBox and exact-linked base-worker health/activity layers; stale or truncated snapshots are never drawn as current. Tiles are game-derived art, so they are never shipped; a one-shot script downloads them into your data volume. @@ -100,6 +101,10 @@ scripts/fetch-pal-icons.sh ./palhelm-data/pal-icons # pal preview icons | `PALHELM_GAME_DATA_ENABLED` | `false` | opt in to the Palworld 1.0 live world-actor snapshot poller; requires server-side game-data support | | `PALHELM_GAME_DATA_INTERVAL` | `30s` | shared game-data snapshot cadence (minimum `15s`; never polled per browser/bot request) | | `PALHELM_GAME_DATA_TIMEOUT` | `10s` | large snapshot request deadline (`1s`–`30s`) | +| `PALHELM_ITEM_GRANTS_ENABLED` | `false` | explicitly enable the admin-only item mutation provider; still requires a matching validated catalogue and fresh ready bridge | +| `PALHELM_ITEM_CATALOG_PATH` | `/item-catalog.json` | operator-installed versioned item catalogue; game data/art are not distributed | +| `PALHELM_ITEM_ICON_DIR` | `/item-icons` | operator-installed same-origin item icons | +| `PALHELM_ITEM_GRANT_SPOOL_DIR` | `/item-grants` | local-only request/result directory shared with a compatible server bridge | | `PALHELM_OODLE_LIB` | unset | path to `liboo2corelinux64.so.9` if you provide your own | | `PALHELM_INTEGRATION_RATE_LIMIT` | `60` | requests/minute per Integration API key | @@ -109,6 +114,14 @@ complete `/data` volume before upgrading; rollback to a 0.8.x image requires res pre-upgrade backup, because the older binary fails closed against the newer schema. See [the v0.9.0 release notes](docs/releases/v0.9.0.md). +The unreleased item-grant work adds migration 013 for a durable audit ledger. The +provider remains disabled by default and is not part of the read-only Integration API. +Its UE4SS bridge remains `validation_required` until an exact-build maintenance-window +test proves the inventory mutation and persistence behavior. See +[the server-mod integration plan](docs/SERVER-MOD-INTEGRATIONS-PLAN.md) before testing +the bridge; Palworld's official server mod loader does not support the native Linux +dedicated-server binary. + ## Known limits Honest notes so you know what you are getting: diff --git a/backend/cmd/item-catalog-import/main.go b/backend/cmd/item-catalog-import/main.go new file mode 100644 index 0000000..4464a56 --- /dev/null +++ b/backend/cmd/item-catalog-import/main.go @@ -0,0 +1,507 @@ +// Command item-catalog-import installs an operator-supplied Palworld item +// manifest and its icons into Palhelm's data directory. Game art is deliberately +// not distributed with Palhelm. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/8tp/palhelm/internal/itemgrant" +) + +const maxIconBytes = 4 << 20 + +var safeID = regexp.MustCompile(`^[A-Za-z0-9_]{1,160}$`) + +var numberedSchematic = regexp.MustCompile(`(?i)\s+Schematic\s+([1-4])$`) + +var schematicRarities = map[string]string{ + "1": "Uncommon", + "2": "Rare", + "3": "Epic", + "4": "Legendary", +} + +type manifest struct { + GameVersion string `json:"gameVersion"` + Source itemgrant.CatalogSource `json:"source"` + Items []manifestItem `json:"items"` +} + +type manifestItem struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + Rarity string `json:"rarity,omitempty"` + IconURL string `json:"iconUrl,omitempty"` + IconPath string `json:"iconPath,omitempty"` + MaxQuantity int `json:"maxQuantity"` + Grantable bool `json:"grantable"` +} + +type palmodsEntry struct { + ID string `json:"id"` + Name string `json:"name"` + HasGameIcon bool `json:"hasGameIcon"` + Group string `json:"group"` + Canonical bool `json:"canonical"` + IconURL string `json:"iconUrl"` +} + +type catalogDocument struct { + SchemaVersion int `json:"schemaVersion"` + GameVersion string `json:"gameVersion"` + Source itemgrant.CatalogSource `json:"source"` + Items []itemgrant.Item `json:"items"` +} + +func main() { + input := flag.String("manifest", "", "operator-supplied source manifest JSON") + palmodsHTML := flag.String("palmods-html", "", "locally saved PalMods item reference HTML (alternative to --manifest)") + gameVersion := flag.String("game-version", "", "exact running Palworld version (required with --palmods-html)") + outCatalog := flag.String("catalog", "./data/item-catalog.json", "output Palhelm catalogue") + outIcons := flag.String("icons", "./data/item-icons", "output icon directory") + outAllowlist := flag.String("allowlist", "", "output bridge allowlist (default: item-allowlist.txt beside catalogue)") + delay := flag.Duration("request-delay", 750*time.Millisecond, "minimum delay between remote icon requests") + validateOnly := flag.Bool("validate-only", false, "validate and summarize the source without downloading or writing") + flag.Parse() + if (*input == "") == (*palmodsHTML == "") { + log.Fatal("set exactly one of --manifest or --palmods-html") + } + if *delay < 100*time.Millisecond { + log.Fatal("--request-delay must be at least 100ms") + } + if *outAllowlist == "" { + *outAllowlist = filepath.Join(filepath.Dir(*outCatalog), "item-allowlist.txt") + } + + var doc manifest + var err error + if *palmodsHTML != "" { + if strings.TrimSpace(*gameVersion) == "" { + log.Fatal("--game-version is required with --palmods-html") + } + doc, err = readPalmodsHTML(*palmodsHTML, *gameVersion) + } else { + doc, err = readManifest(*input) + } + if err != nil { + log.Fatal(err) + } + if *validateOnly { + grantable, icons, err := validateManifest(doc) + if err != nil { + log.Fatal(err) + } + log.Printf("validated %d item records for Palworld %s: %d grantable, %d icons", len(doc.Items), doc.GameVersion, grantable, icons) + return + } + if err := os.MkdirAll(*outIcons, 0o700); err != nil { + log.Fatal(err) + } + client := &http.Client{Timeout: 20 * time.Second} + out := catalogDocument{SchemaVersion: itemgrant.CatalogSchemaVersion, GameVersion: strings.TrimSpace(doc.GameVersion), Source: doc.Source, Items: make([]itemgrant.Item, 0, len(doc.Items))} + seen := make(map[string]bool, len(doc.Items)) + lastRequest := time.Time{} + for index, source := range doc.Items { + item, iconSource, err := prepareItem(source) + if err != nil { + log.Fatalf("item %d: %v", index, err) + } + key := strings.ToLower(item.ID) + if seen[key] { + log.Fatalf("item %d: duplicate id %q", index, item.ID) + } + seen[key] = true + if iconSource != "" { + ext, extErr := iconExtension(iconSource) + if extErr != nil { + log.Fatalf("item %s icon: %v", item.ID, extErr) + } + item.Icon = strings.ToLower(item.ID) + ext + target := filepath.Join(*outIcons, item.Icon) + if stat, statErr := os.Stat(target); statErr != nil || stat.Size() == 0 { + if strings.HasPrefix(iconSource, "https://") { + if wait := *delay - time.Since(lastRequest); wait > 0 { + time.Sleep(wait) + } + err = downloadIcon(client, iconSource, target) + lastRequest = time.Now() + } else { + err = copyIcon(iconSource, target) + } + if err != nil { + log.Fatalf("item %s icon: %v", item.ID, err) + } + if err := validateIcon(target, ext); err != nil { + _ = os.Remove(target) + log.Fatalf("item %s icon: %v", item.ID, err) + } + } + } else if item.Grantable { + log.Fatalf("item %s is grantable but has no iconUrl or iconPath", item.ID) + } + out.Items = append(out.Items, item) + } + if out.GameVersion == "" || strings.TrimSpace(out.Source.Name) == "" { + log.Fatal("gameVersion and source.name are required") + } + if err := writeCatalog(*outCatalog, out); err != nil { + log.Fatal(err) + } + if err := writeAllowlist(*outAllowlist, out); err != nil { + log.Fatal(err) + } + if _, err := itemgrant.LoadCatalog(*outCatalog); err != nil { + log.Fatalf("post-write validation failed: %v", err) + } + log.Printf("installed %d item records for Palworld %s; catalogue=%s icons=%s allowlist=%s", len(out.Items), out.GameVersion, *outCatalog, *outIcons, *outAllowlist) +} + +func readPalmodsHTML(path, gameVersion string) (manifest, error) { + f, err := os.Open(path) + if err != nil { + return manifest{}, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, (32<<20)+1)) + if err != nil { + return manifest{}, err + } + if len(b) > 32<<20 { + return manifest{}, errors.New("PalMods HTML exceeds 32 MiB") + } + entries, err := parsePalmodsEntries(string(b)) + if err != nil { + return manifest{}, err + } + if len(entries) < 2000 || len(entries) > 10000 { + return manifest{}, fmt.Errorf("refusing implausible PalMods item count %d", len(entries)) + } + doc := manifest{GameVersion: strings.TrimSpace(gameVersion), Source: itemgrant.CatalogSource{Name: "PalMods game-ID reference (operator import)", URL: "https://www.palmods.gg/docs/authors/game-ids/items", GeneratedAt: time.Now().UTC().Format(time.RFC3339)}, Items: make([]manifestItem, 0, len(entries))} + rarities := palmodsRarities(entries) + for _, entry := range entries { + maximum, permittedGroup := palmodsQuantityPolicy(entry.Group) + grantable := entry.Canonical && entry.HasGameIcon && permittedGroup + if !grantable { + maximum = 1 + } + iconURL := "" + if entry.HasGameIcon { + iconURL = entry.IconURL + } + name := strings.TrimSpace(entry.Name) + if name == "" || name == "-" || strings.HasPrefix(name, "PV_") { + name = entry.ID + } + doc.Items = append(doc.Items, manifestItem{ID: entry.ID, Name: name, Category: entry.Group, Rarity: rarities[strings.ToLower(entry.ID)], IconURL: iconURL, MaxQuantity: maximum, Grantable: grantable}) + } + return doc, nil +} + +// palmodsRarities derives only grades that the game IDs and localized names make +// unambiguous. Numbered schematics use Palworld's documented 1–4 quality ladder, +// and Blueprint_ points at the exact equipment variant it creates. A +// base weapon/armor/glider sharing that variant's localized name is the common +// recipe. Special schematics without a numbered grade remain deliberately blank. +func palmodsRarities(entries []palmodsEntry) map[string]string { + byID := make(map[string]palmodsEntry, len(entries)) + result := make(map[string]string) + gradedNames := make(map[string]bool) + for _, entry := range entries { + byID[strings.ToLower(entry.ID)] = entry + } + for _, entry := range entries { + if entry.Group != "Schematic" { + continue + } + match := numberedSchematic.FindStringSubmatch(strings.TrimSpace(entry.Name)) + if len(match) != 2 { + continue + } + rarity := schematicRarities[match[1]] + result[strings.ToLower(entry.ID)] = rarity + targetID := strings.TrimPrefix(entry.ID, "Blueprint_") + if target, ok := byID[strings.ToLower(targetID)]; ok { + result[strings.ToLower(target.ID)] = rarity + if isQualityEquipment(target.Group) { + gradedNames[strings.ToLower(strings.TrimSpace(target.Name))] = true + } + } + } + for _, entry := range entries { + key := strings.ToLower(entry.ID) + if _, graded := result[key]; graded || !isQualityEquipment(entry.Group) { + continue + } + if gradedNames[strings.ToLower(strings.TrimSpace(entry.Name))] { + result[key] = "Common" + } + } + return result +} + +func isQualityEquipment(group string) bool { + switch group { + case "Weapon", "Armor", "Glider": + return true + default: + return false + } +} + +func validateManifest(doc manifest) (grantable, icons int, err error) { + if strings.TrimSpace(doc.GameVersion) == "" || strings.TrimSpace(doc.Source.Name) == "" { + return 0, 0, errors.New("gameVersion and source.name are required") + } + seen := make(map[string]bool, len(doc.Items)) + for index, source := range doc.Items { + item, iconSource, prepareErr := prepareItem(source) + if prepareErr != nil { + return 0, 0, fmt.Errorf("item %d: %w", index, prepareErr) + } + key := strings.ToLower(item.ID) + if seen[key] { + return 0, 0, fmt.Errorf("item %d: duplicate id %q", index, item.ID) + } + seen[key] = true + if iconSource != "" { + icons++ + } + if item.Grantable { + grantable++ + if iconSource == "" { + return 0, 0, fmt.Errorf("item %s is grantable but has no iconUrl or iconPath", item.ID) + } + } + } + return grantable, icons, nil +} + +func parsePalmodsEntries(body string) ([]palmodsEntry, error) { + startMarker := `\"entries\":[` + endMarker := `}]},\"categories\"` + start := strings.Index(body, startMarker) + if start < 0 { + return nil, errors.New("PalMods item entries were not found; the page format may have changed") + } + start += len(startMarker) - 1 + endRelative := strings.Index(body[start:], endMarker) + if endRelative < 0 { + return nil, errors.New("PalMods item entry terminator was not found; the page format may have changed") + } + escaped := body[start : start+endRelative+2] + unescaped, err := strconv.Unquote(`"` + escaped + `"`) + if err != nil { + return nil, fmt.Errorf("decode PalMods page payload: %w", err) + } + var entries []palmodsEntry + if err := json.Unmarshal([]byte(unescaped), &entries); err != nil { + return nil, fmt.Errorf("decode PalMods entries: %w", err) + } + return entries, nil +} + +func palmodsQuantityPolicy(group string) (int, bool) { + switch group { + case "Material", "Ore & Gem", "Pal Drop": + return 9999, true + case "Ammo", "Food", "Medicine", "Sphere", "Seed", "Consumable": + return 999, true + case "Weapon", "Accessory", "Armor", "Glider", "Pal Gear", "Schematic": + return 10, true + default: + // Key Item and Internal & other are intentionally denylisted. + return 1, false + } +} + +func readManifest(path string) (manifest, error) { + f, err := os.Open(path) + if err != nil { + return manifest{}, err + } + defer f.Close() + var doc manifest + dec := json.NewDecoder(io.LimitReader(f, 32<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(&doc); err != nil { + return manifest{}, fmt.Errorf("decode manifest: %w", err) + } + if len(doc.Items) < 1 || len(doc.Items) > 10000 { + return manifest{}, errors.New("manifest must contain 1 to 10000 items") + } + return doc, nil +} + +func prepareItem(source manifestItem) (itemgrant.Item, string, error) { + source.ID, source.Name = strings.TrimSpace(source.ID), strings.TrimSpace(source.Name) + if !safeID.MatchString(source.ID) { + return itemgrant.Item{}, "", fmt.Errorf("invalid id %q", source.ID) + } + if source.Name == "" || len(source.Name) > 160 { + return itemgrant.Item{}, "", errors.New("name is required and must not exceed 160 characters") + } + if source.MaxQuantity < 1 || source.MaxQuantity > 9999 { + return itemgrant.Item{}, "", errors.New("maxQuantity must be from 1 to 9999") + } + if source.IconURL != "" && source.IconPath != "" { + return itemgrant.Item{}, "", errors.New("set only one of iconUrl and iconPath") + } + iconSource := strings.TrimSpace(source.IconURL) + if iconSource != "" { + u, err := url.Parse(iconSource) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return itemgrant.Item{}, "", errors.New("iconUrl must be an absolute HTTPS URL without credentials") + } + } else if source.IconPath != "" { + absolute, err := filepath.Abs(source.IconPath) + if err != nil { + return itemgrant.Item{}, "", err + } + iconSource = absolute + } + return itemgrant.Item{ID: source.ID, Name: source.Name, Description: strings.TrimSpace(source.Description), Category: strings.TrimSpace(source.Category), Rarity: strings.TrimSpace(source.Rarity), MaxQuantity: source.MaxQuantity, Grantable: source.Grantable}, iconSource, nil +} + +func iconExtension(source string) (string, error) { + ext := strings.ToLower(filepath.Ext(strings.Split(source, "?")[0])) + switch ext { + case ".png", ".jpg", ".jpeg", ".webp": + return ext, nil + } + return "", errors.New("icon source must end in .png, .jpg, .jpeg, or .webp") +} + +func validateIcon(path, extension string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + header := make([]byte, 16) + n, err := io.ReadFull(f, header) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) { + return err + } + header = header[:n] + valid := false + switch extension { + case ".png": + valid = len(header) >= 8 && string(header[:8]) == "\x89PNG\r\n\x1a\n" + case ".jpg", ".jpeg": + valid = len(header) >= 3 && header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff + case ".webp": + valid = len(header) >= 12 && string(header[:4]) == "RIFF" && string(header[8:12]) == "WEBP" + } + if !valid { + return fmt.Errorf("file content does not match %s", extension) + } + return nil +} + +func downloadIcon(client *http.Client, source, target string) error { + req, err := http.NewRequest(http.MethodGet, source, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "PalhelmItemCatalogImporter/1.0 (+https://github.com/8tp/palhelm)") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("source returned HTTP %d", resp.StatusCode) + } + if contentType := resp.Header.Get("Content-Type"); contentType != "" && !strings.HasPrefix(contentType, "image/") { + return fmt.Errorf("source returned %q, not an image", contentType) + } + return writeAtomic(target, io.LimitReader(resp.Body, maxIconBytes+1), true) +} + +func copyIcon(source, target string) error { + f, err := os.Open(source) + if err != nil { + return err + } + defer f.Close() + return writeAtomic(target, io.LimitReader(f, maxIconBytes+1), true) +} + +func writeAtomic(target string, source io.Reader, enforceSize bool) error { + tmp, err := os.CreateTemp(filepath.Dir(target), ".item-icon-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + n, copyErr := io.Copy(tmp, source) + closeErr := tmp.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if n == 0 || (enforceSize && n > maxIconBytes) { + return fmt.Errorf("icon size %d is invalid", n) + } + if err := os.Chmod(tmpName, 0o600); err != nil { + return err + } + return os.Rename(tmpName, target) +} + +func writeCatalog(target string, doc catalogDocument) error { + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + return writeAtomic(target, strings.NewReader(string(append(b, '\n'))), false) +} + +func writeAllowlist(target string, doc catalogDocument) error { + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + var body strings.Builder + body.WriteString("# palhelm-item-allowlist-v1\n") + body.WriteString("# gameVersion=" + doc.GameVersion + "\n") + count := 0 + for _, item := range doc.Items { + if item.Grantable { + count++ + } + } + body.WriteString(fmt.Sprintf("# catalogItems=%d\n", count)) + for _, item := range doc.Items { + if item.Grantable { + body.WriteString(fmt.Sprintf("%s\t%d\n", item.ID, item.MaxQuantity)) + } + } + return writeAtomic(target, strings.NewReader(body.String()), false) +} + +func init() { + // Register common image types on minimal systems where mime's OS database is absent. + _ = mime.AddExtensionType(".webp", "image/webp") +} diff --git a/backend/cmd/item-catalog-import/main_test.go b/backend/cmd/item-catalog-import/main_test.go new file mode 100644 index 0000000..4112c5f --- /dev/null +++ b/backend/cmd/item-catalog-import/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/8tp/palhelm/internal/itemgrant" +) + +func TestPrepareItemRequiresGrantableIconAndSafeURL(t *testing.T) { + _, source, err := prepareItem(manifestItem{ID: "Stone", Name: "Stone", IconURL: "https://assets.example/stone.webp", MaxQuantity: 9999, Grantable: true}) + if err != nil || source == "" { + t.Fatalf("prepare valid item source=%q err=%v", source, err) + } + if _, _, err := prepareItem(manifestItem{ID: "../Stone", Name: "Stone", IconURL: "https://assets.example/stone.webp", MaxQuantity: 1}); err == nil { + t.Fatal("unsafe item id accepted") + } + if _, _, err := prepareItem(manifestItem{ID: "Stone", Name: "Stone", IconURL: "http://assets.example/stone.webp", MaxQuantity: 1}); err == nil { + t.Fatal("non-HTTPS icon accepted") + } +} + +func TestValidateIconAndAllowlistOutput(t *testing.T) { + dir := t.TempDir() + icon := filepath.Join(dir, "stone.webp") + if err := os.WriteFile(icon, []byte("RIFF0000WEBPpayload"), 0o600); err != nil { + t.Fatal(err) + } + if err := validateIcon(icon, ".webp"); err != nil { + t.Fatal(err) + } + if err := validateIcon(icon, ".png"); err == nil { + t.Fatal("WEBP accepted as PNG") + } + doc := catalogDocument{GameVersion: "1.0.1", Items: []itemgrant.Item{{ID: "Stone", MaxQuantity: 9999, Grantable: true}, {ID: "DebugOnly", MaxQuantity: 1, Grantable: false}}} + allowlist := filepath.Join(dir, "allowlist.txt") + if err := writeAllowlist(allowlist, doc); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(allowlist) + if err != nil { + t.Fatal(err) + } + text := string(body) + if !strings.Contains(text, "# catalogItems=1\n") || !strings.Contains(text, "Stone\t9999\n") || strings.Contains(text, "DebugOnly") { + t.Fatalf("allowlist = %q", text) + } +} + +func TestParsePalmodsEntriesAndSafetyPolicy(t *testing.T) { + escaped := `\"entries\":[{\"id\":\"Wood\",\"name\":\"Wood\",\"hasGameIcon\":true,\"group\":\"Material\",\"canonical\":true,\"iconUrl\":\"https://assets.example/wood.webp\"}]},\"categories\"` + entries, err := parsePalmodsEntries(escaped) + if err != nil || len(entries) != 1 || entries[0].ID != "Wood" { + t.Fatalf("entries=%#v err=%v", entries, err) + } + if maximum, allowed := palmodsQuantityPolicy("Material"); !allowed || maximum != 9999 { + t.Fatalf("material policy=%d,%v", maximum, allowed) + } + for _, group := range []string{"Key Item", "Internal & other", "unknown"} { + if _, allowed := palmodsQuantityPolicy(group); allowed { + t.Fatalf("unsafe group %q allowed", group) + } + } +} + +func TestPalmodsRaritiesFollowNumberedSchematicGrades(t *testing.T) { + entries := []palmodsEntry{ + {ID: "CopperArmor", Name: "Metal Armor", Group: "Armor"}, + {ID: "CopperArmor_2", Name: "Metal Armor", Group: "Armor"}, + {ID: "CopperArmor_3", Name: "Metal Armor", Group: "Armor"}, + {ID: "CopperArmor_4", Name: "Metal Armor", Group: "Armor"}, + {ID: "CopperArmor_5", Name: "Metal Armor", Group: "Armor"}, + {ID: "Blueprint_CopperArmor_2", Name: "Metal Armor Schematic 1", Group: "Schematic"}, + {ID: "Blueprint_CopperArmor_3", Name: "Metal Armor Schematic 2", Group: "Schematic"}, + {ID: "Blueprint_CopperArmor_4", Name: "Metal Armor Schematic 3", Group: "Schematic"}, + {ID: "Blueprint_CopperArmor_5", Name: "Metal Armor Schematic 4", Group: "Schematic"}, + {ID: "DecalGun_2", Name: "Decal Gun 2", Group: "Weapon"}, + {ID: "Blueprint_Special", Name: "Special Schematic", Group: "Schematic"}, + } + want := map[string]string{ + "copperarmor": "Common", + "copperarmor_2": "Uncommon", + "copperarmor_3": "Rare", + "copperarmor_4": "Epic", + "copperarmor_5": "Legendary", + "blueprint_copperarmor_2": "Uncommon", + "blueprint_copperarmor_3": "Rare", + "blueprint_copperarmor_4": "Epic", + "blueprint_copperarmor_5": "Legendary", + } + got := palmodsRarities(entries) + for id, rarity := range want { + if got[id] != rarity { + t.Errorf("rarity[%q] = %q, want %q", id, got[id], rarity) + } + } + for _, id := range []string{"decalgun_2", "blueprint_special"} { + if got[id] != "" { + t.Errorf("ambiguous item %q received rarity %q", id, got[id]) + } + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 67d8bda..a2cefc7 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -27,6 +27,12 @@ type Config struct { MetricsInterval, PlayersInterval, SaveSyncInterval time.Duration GameDataInterval, GameDataTimeout time.Duration IntegrationRateLimit int + // Item grants are an optional, fail-closed mutation integration. The panel + // never enables them merely because a spool or catalogue happens to exist. + ItemGrantsEnabled bool + ItemCatalogPath string + ItemIconDir string + ItemGrantSpoolDir string // SessionDays is how long a login session cookie stays valid, in whole days. SessionDays int } @@ -61,6 +67,21 @@ func Load() (Config, error) { if c.GameDataEnabled, err = optionalBool("PALHELM_GAME_DATA_ENABLED", false); err != nil { return c, err } + if c.ItemGrantsEnabled, err = optionalBool("PALHELM_ITEM_GRANTS_ENABLED", false); err != nil { + return c, err + } + c.ItemCatalogPath = strings.TrimSpace(os.Getenv("PALHELM_ITEM_CATALOG_PATH")) + if c.ItemCatalogPath == "" { + c.ItemCatalogPath = filepath.Join(c.DataDir, "item-catalog.json") + } + c.ItemIconDir = strings.TrimSpace(os.Getenv("PALHELM_ITEM_ICON_DIR")) + if c.ItemIconDir == "" { + c.ItemIconDir = filepath.Join(c.DataDir, "item-icons") + } + c.ItemGrantSpoolDir = strings.TrimSpace(os.Getenv("PALHELM_ITEM_GRANT_SPOOL_DIR")) + if c.ItemGrantSpoolDir == "" { + c.ItemGrantSpoolDir = filepath.Join(c.DataDir, "item-grants") + } if c.MetricsInterval, err = duration("PALHELM_METRICS_INTERVAL", 5*time.Second); err != nil { return c, err } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2cf2e77..e44392b 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "path/filepath" "testing" "time" ) @@ -81,6 +82,46 @@ func TestLoadGameDataDefaultsDisabled(t *testing.T) { } } +func TestLoadItemGrantsDefaultsFailClosed(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("PALHELM_DATA_DIR", dataDir) + t.Setenv("PALHELM_ITEM_GRANTS_ENABLED", "") + t.Setenv("PALHELM_ITEM_CATALOG_PATH", "") + t.Setenv("PALHELM_ITEM_ICON_DIR", "") + t.Setenv("PALHELM_ITEM_GRANT_SPOOL_DIR", "") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.ItemGrantsEnabled { + t.Fatal("item grants defaulted enabled") + } + if cfg.ItemCatalogPath != filepath.Join(dataDir, "item-catalog.json") || cfg.ItemIconDir != filepath.Join(dataDir, "item-icons") || cfg.ItemGrantSpoolDir != filepath.Join(dataDir, "item-grants") { + t.Fatalf("item grant paths = %#v", cfg) + } +} + +func TestLoadItemGrantOverrides(t *testing.T) { + t.Setenv("PALHELM_ITEM_GRANTS_ENABLED", "true") + t.Setenv("PALHELM_ITEM_CATALOG_PATH", "/catalog/items.json") + t.Setenv("PALHELM_ITEM_ICON_DIR", "/catalog/icons") + t.Setenv("PALHELM_ITEM_GRANT_SPOOL_DIR", "/bridge/spool") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if !cfg.ItemGrantsEnabled || cfg.ItemCatalogPath != "/catalog/items.json" || cfg.ItemIconDir != "/catalog/icons" || cfg.ItemGrantSpoolDir != "/bridge/spool" { + t.Fatalf("item grant overrides = %#v", cfg) + } +} + +func TestLoadRejectsInvalidItemGrantBoolean(t *testing.T) { + t.Setenv("PALHELM_ITEM_GRANTS_ENABLED", "sometimes") + if _, err := Load(); err == nil { + t.Fatal("expected invalid item grant boolean to fail") + } +} + func TestLoadGameDataOverrides(t *testing.T) { t.Setenv("PALHELM_GAME_DATA_ENABLED", "true") t.Setenv("PALHELM_GAME_DATA_INTERVAL", "45s") diff --git a/backend/internal/itemgrant/catalog.go b/backend/internal/itemgrant/catalog.go new file mode 100644 index 0000000..68ffba2 --- /dev/null +++ b/backend/internal/itemgrant/catalog.go @@ -0,0 +1,163 @@ +// Package itemgrant implements Palhelm's fail-closed item mutation boundary. +package itemgrant + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const CatalogSchemaVersion = 1 + +var safeID = regexp.MustCompile(`^[A-Za-z0-9_]{1,160}$`) + +type CatalogSource struct { + Name string `json:"name"` + URL string `json:"url,omitempty"` + GeneratedAt string `json:"generatedAt,omitempty"` +} + +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + Rarity string `json:"rarity,omitempty"` + Icon string `json:"icon,omitempty"` + MaxQuantity int `json:"maxQuantity"` + Grantable bool `json:"grantable"` +} + +type catalogDocument struct { + SchemaVersion int `json:"schemaVersion"` + GameVersion string `json:"gameVersion"` + Source CatalogSource `json:"source"` + Items []Item `json:"items"` +} + +type Catalog struct { + gameVersion string + source CatalogSource + items []Item + byID map[string]Item + grantable int +} + +func LoadCatalog(path string) (*Catalog, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, (32<<20)+1)) + if err != nil { + return nil, err + } + if len(b) > 32<<20 { + return nil, errors.New("item catalogue exceeds 32 MiB") + } + var doc catalogDocument + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.DisallowUnknownFields() + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("decode item catalogue: %w", err) + } + var trailing any + if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, errors.New("item catalogue contains trailing data") + } + if doc.SchemaVersion != CatalogSchemaVersion { + return nil, fmt.Errorf("unsupported item catalogue schema %d", doc.SchemaVersion) + } + if strings.TrimSpace(doc.GameVersion) == "" { + return nil, errors.New("item catalogue gameVersion is required") + } + if strings.TrimSpace(doc.Source.Name) == "" { + return nil, errors.New("item catalogue source.name is required") + } + if len(doc.Items) == 0 || len(doc.Items) > 10000 { + return nil, fmt.Errorf("item catalogue must contain 1 to 10000 items") + } + c := &Catalog{gameVersion: strings.TrimSpace(doc.GameVersion), source: doc.Source, byID: make(map[string]Item, len(doc.Items))} + for i, item := range doc.Items { + item.ID = strings.TrimSpace(item.ID) + item.Name = strings.TrimSpace(item.Name) + item.Icon = strings.TrimSpace(item.Icon) + if !safeID.MatchString(item.ID) { + return nil, fmt.Errorf("item %d has invalid id %q", i, item.ID) + } + if item.Name == "" || len(item.Name) > 160 { + return nil, fmt.Errorf("item %q has an invalid name", item.ID) + } + if item.Icon != "" && (filepath.Base(item.Icon) != item.Icon || strings.ContainsAny(item.Icon, `/\\`)) { + return nil, fmt.Errorf("item %q icon must be a file name", item.ID) + } + if item.MaxQuantity < 1 || item.MaxQuantity > 9999 { + return nil, fmt.Errorf("item %q maxQuantity must be from 1 to 9999", item.ID) + } + key := strings.ToLower(item.ID) + if _, exists := c.byID[key]; exists { + return nil, fmt.Errorf("duplicate item id %q", item.ID) + } + c.byID[key] = item + c.items = append(c.items, item) + if item.Grantable { + c.grantable++ + } + } + sort.Slice(c.items, func(i, j int) bool { return strings.ToLower(c.items[i].Name) < strings.ToLower(c.items[j].Name) }) + return c, nil +} + +func (c *Catalog) GameVersion() string { return c.gameVersion } +func (c *Catalog) Source() CatalogSource { return c.source } +func (c *Catalog) Count() int { return len(c.items) } +func (c *Catalog) GrantableCount() int { return c.grantable } + +func (c *Catalog) Item(id string) (Item, bool) { + item, ok := c.byID[strings.ToLower(strings.TrimSpace(id))] + return item, ok +} + +func (c *Catalog) Search(query string, limit, offset int) ([]Item, int) { + if limit < 1 || limit > 100 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + q := strings.ToLower(strings.TrimSpace(query)) + terms := strings.Fields(q) + matches := make([]Item, 0) + for _, item := range c.items { + if !item.Grantable { + continue + } + haystack := strings.ToLower(strings.Join([]string{item.Name, item.ID, item.Category, item.Rarity}, " ")) + matched := true + for _, term := range terms { + if !strings.Contains(haystack, term) { + matched = false + break + } + } + if matched { + matches = append(matches, item) + } + } + total := len(matches) + if offset >= total { + return []Item{}, total + } + end := offset + limit + if end > total { + end = total + } + return append([]Item(nil), matches[offset:end]...), total +} diff --git a/backend/internal/itemgrant/catalog_test.go b/backend/internal/itemgrant/catalog_test.go new file mode 100644 index 0000000..cbf462b --- /dev/null +++ b/backend/internal/itemgrant/catalog_test.go @@ -0,0 +1,75 @@ +package itemgrant + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func writeTestCatalog(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "items.json") + doc := `{"schemaVersion":1,"gameVersion":"1.0.1","source":{"name":"test"},"items":[{"id":"Stone","name":"Stone","category":"Material","icon":"stone.webp","maxQuantity":9999,"grantable":true},{"id":"CopperArmor_5","name":"Metal Armor","category":"Armor","rarity":"Legendary","icon":"armor.webp","maxQuantity":10,"grantable":true},{"id":"DebugOnly","name":"Debug Only","maxQuantity":1,"grantable":false}]}` + if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestCatalogSearchAndDenyByDefault(t *testing.T) { + catalog, err := LoadCatalog(writeTestCatalog(t, t.TempDir())) + if err != nil { + t.Fatal(err) + } + items, total := catalog.Search("material", 10, 0) + if total != 1 || len(items) != 1 || items[0].ID != "Stone" { + t.Fatalf("search = %#v total=%d", items, total) + } + items, total = catalog.Search("debug", 10, 0) + if total != 0 || len(items) != 0 { + t.Fatalf("non-grantable item leaked into search: %#v", items) + } + items, total = catalog.Search("legendary armor", 10, 0) + if total != 1 || len(items) != 1 || items[0].ID != "CopperArmor_5" { + t.Fatalf("rarity search = %#v total=%d", items, total) + } +} + +func TestSpoolCapabilityAndAtomicRequest(t *testing.T) { + dir := t.TempDir() + catalog, err := LoadCatalog(writeTestCatalog(t, dir)) + if err != nil { + t.Fatal(err) + } + spool := Spool{Dir: filepath.Join(dir, "spool")} + if got := spool.Capability(true, catalog, time.Now()); got.Ready { + t.Fatalf("missing bridge reported ready: %#v", got) + } + if err := os.MkdirAll(spool.Dir, 0o700); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + heartbeat := `{"protocolVersion":1,"state":"ready","gameVersion":"1.0.1","catalogVersion":"1.0.1","catalogItems":2,"at":"` + now.Format(time.RFC3339) + `"}` + if err := os.WriteFile(filepath.Join(spool.Dir, "capability.json"), []byte(heartbeat), 0o600); err != nil { + t.Fatal(err) + } + if got := spool.Capability(true, catalog, now); !got.Ready { + t.Fatalf("valid bridge not ready: %#v", got) + } + req := Request{ProtocolVersion: 1, RequestID: "12345678", CreatedAt: now.Format(time.RFC3339), PlayerUID: "abcdef12", PlayerName: "Player", ItemID: "Stone", Quantity: 10} + if err := spool.Submit(req); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(spool.Dir, "requests", "12345678.json")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(spool.Dir, "inbox.json")); err != nil { + t.Fatal(err) + } + req.RequestID = "87654321" + if err := spool.Submit(req); !errors.Is(err, ErrBridgeBusy) { + t.Fatalf("duplicate submit error = %v, want ErrBridgeBusy", err) + } +} diff --git a/backend/internal/itemgrant/spool.go b/backend/internal/itemgrant/spool.go new file mode 100644 index 0000000..5ca98b2 --- /dev/null +++ b/backend/internal/itemgrant/spool.go @@ -0,0 +1,185 @@ +package itemgrant + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const BridgeProtocolVersion = 1 +const heartbeatMaxAge = 45 * time.Second + +var ErrBridgeBusy = errors.New("server bridge already has a pending request") + +type Capability struct { + Enabled bool `json:"enabled"` + Ready bool `json:"ready"` + Reason string `json:"reason"` + ProtocolVersion int `json:"protocolVersion"` + GameVersion string `json:"gameVersion,omitempty"` + CatalogVersion string `json:"catalogVersion,omitempty"` + CatalogItems int `json:"catalogItems"` + LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` +} + +type heartbeat struct { + ProtocolVersion int `json:"protocolVersion"` + State string `json:"state"` + GameVersion string `json:"gameVersion"` + CatalogVersion string `json:"catalogVersion"` + CatalogItems int `json:"catalogItems"` + At string `json:"at"` +} + +type Request struct { + ProtocolVersion int `json:"protocolVersion"` + RequestID string `json:"requestId"` + CreatedAt string `json:"createdAt"` + PlayerUID string `json:"playerUid"` + PlayerName string `json:"playerName"` + ItemID string `json:"itemId"` + Quantity int `json:"quantity"` +} + +type Result struct { + ProtocolVersion int `json:"protocolVersion"` + RequestID string `json:"requestId"` + Status string `json:"status"` + GrantedQuantity int `json:"grantedQuantity,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + CompletedAt string `json:"completedAt"` +} + +type Spool struct{ Dir string } + +func NewRequestID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (s Spool) Capability(enabled bool, catalog *Catalog, now time.Time) Capability { + c := Capability{Enabled: enabled, ProtocolVersion: BridgeProtocolVersion} + if catalog != nil { + c.CatalogVersion = catalog.GameVersion() + c.CatalogItems = catalog.GrantableCount() + } + if !enabled { + c.Reason = "Item grants are disabled in Palhelm configuration." + return c + } + if catalog == nil { + c.Reason = "No validated item catalogue is installed." + return c + } + b, err := os.ReadFile(filepath.Join(s.Dir, "capability.json")) + if err != nil { + c.Reason = "The server bridge has not reported ready." + return c + } + var h heartbeat + if json.Unmarshal(b, &h) != nil || h.ProtocolVersion != BridgeProtocolVersion { + c.Reason = "The server bridge protocol is incompatible." + return c + } + c.GameVersion, c.LastHeartbeatAt = strings.TrimSpace(h.GameVersion), h.At + at, err := time.Parse(time.RFC3339, h.At) + if err != nil || now.Sub(at) < 0 || now.Sub(at) > heartbeatMaxAge { + c.Reason = "The server bridge heartbeat is stale." + return c + } + if h.State != "ready" { + c.Reason = "The server bridge is not ready." + return c + } + if h.CatalogVersion != catalog.GameVersion() || h.GameVersion != catalog.GameVersion() { + c.Reason = "The server and item catalogue versions do not match." + return c + } + if h.CatalogItems != catalog.GrantableCount() { + c.Reason = "The server bridge allowlist does not match the item catalogue." + return c + } + c.Ready = true + return c +} + +func (s Spool) Submit(req Request) error { + if req.ProtocolVersion != BridgeProtocolVersion || req.RequestID == "" || !safeID.MatchString(req.PlayerUID) || !safeID.MatchString(req.ItemID) || req.Quantity < 1 || req.Quantity > 9999 { + return errors.New("invalid grant request") + } + dir := filepath.Join(s.Dir, "requests") + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(s.Dir, "results"), 0o700); err != nil { + return err + } + b, err := json.Marshal(req) + if err != nil { + return err + } + target := filepath.Join(dir, req.RequestID+".json") + if _, err := os.Stat(target); err == nil { + return os.ErrExist + } + tmp := target + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Link(target, filepath.Join(s.Dir, "inbox.json")); err != nil { + if os.IsExist(err) { + return ErrBridgeBusy + } + return err + } + return nil +} + +func (s Spool) Result(requestID string) (Result, error) { + if !safeID.MatchString(requestID) { + return Result{}, errors.New("invalid request id") + } + f, err := os.Open(filepath.Join(s.Dir, "results", requestID+".json")) + if err != nil { + return Result{}, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, (64<<10)+1)) + if err != nil { + return Result{}, err + } + if len(b) > 64<<10 { + return Result{}, errors.New("bridge result exceeds 64 KiB") + } + var result Result + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.DisallowUnknownFields() + if err := dec.Decode(&result); err != nil { + return Result{}, fmt.Errorf("decode bridge result: %w", err) + } + if result.ProtocolVersion != BridgeProtocolVersion || result.RequestID != requestID || (result.Status != "succeeded" && result.Status != "failed") { + return Result{}, errors.New("invalid bridge result") + } + if len(result.ErrorCode) > 100 || len(result.ErrorMessage) > 500 { + return Result{}, errors.New("bridge result error fields are too long") + } + if _, err := time.Parse(time.RFC3339, result.CompletedAt); err != nil { + return Result{}, errors.New("bridge result completedAt is invalid") + } + return result, nil +} diff --git a/backend/internal/server/item_grants.go b/backend/internal/server/item_grants.go new file mode 100644 index 0000000..eafdb06 --- /dev/null +++ b/backend/internal/server/item_grants.go @@ -0,0 +1,223 @@ +package server + +import ( + "database/sql" + "errors" + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/8tp/palhelm/internal/itemgrant" + "github.com/8tp/palhelm/internal/store" + "github.com/go-chi/chi/v5" +) + +var idempotencyKeyPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]{8,128}$`) + +func (s *Server) itemGrantCapability(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.itemSpool.Capability(s.cfg.ItemGrantsEnabled, s.itemCatalog, time.Now().UTC())) +} + +func (s *Server) items(w http.ResponseWriter, r *http.Request) { + if s.itemCatalog == nil { + writeJSON(w, http.StatusOK, map[string]any{"items": []any{}, "total": 0, "gameVersion": "", "source": nil}) + return + } + limit := 50 + offset := 0 + var err error + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 100 { + writeError(w, 400, "invalid_limit", "limit must be from 1 to 100.") + return + } + } + if raw := r.URL.Query().Get("offset"); raw != "" { + offset, err = strconv.Atoi(raw) + if err != nil || offset < 0 || offset > 10000 { + writeError(w, 400, "invalid_offset", "offset must be from 0 to 10000.") + return + } + } + items, total := s.itemCatalog.Search(r.URL.Query().Get("q"), limit, offset) + writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "gameVersion": s.itemCatalog.GameVersion(), "source": s.itemCatalog.Source()}) +} + +func (s *Server) itemIcon(w http.ResponseWriter, r *http.Request) { + if s.itemCatalog == nil { + http.NotFound(w, r) + return + } + item, ok := s.itemCatalog.Item(chi.URLParam(r, "itemId")) + if !ok || item.Icon == "" { + http.NotFound(w, r) + return + } + path := filepath.Join(s.cfg.ItemIconDir, item.Icon) + if filepath.Dir(path) != filepath.Clean(s.cfg.ItemIconDir) { + http.NotFound(w, r) + return + } + f, err := os.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + stat, err := f.Stat() + if err != nil || !stat.Mode().IsRegular() { + http.NotFound(w, r) + return + } + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(item.Icon))) + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "private, max-age=86400") + http.ServeContent(w, r, item.Icon, stat.ModTime(), f) +} + +type createGrantRequest struct { + ItemID string `json:"itemId"` + Quantity int `json:"quantity"` + Reason string `json:"reason"` + IdempotencyKey string `json:"idempotencyKey"` +} + +func (s *Server) createItemGrant(w http.ResponseWriter, r *http.Request) { + capability := s.itemSpool.Capability(s.cfg.ItemGrantsEnabled, s.itemCatalog, time.Now().UTC()) + if !capability.Ready { + writeError(w, http.StatusServiceUnavailable, "item_grants_unavailable", capability.Reason) + return + } + var req createGrantRequest + if !decode(w, r, &req) { + return + } + req.ItemID, req.Reason, req.IdempotencyKey = strings.TrimSpace(req.ItemID), strings.TrimSpace(req.Reason), strings.TrimSpace(req.IdempotencyKey) + if !idempotencyKeyPattern.MatchString(req.IdempotencyKey) { + writeError(w, 400, "invalid_idempotency_key", "idempotencyKey must be 8 to 128 safe characters.") + return + } + if len(req.Reason) < 3 || len(req.Reason) > 200 { + writeError(w, 400, "invalid_reason", "reason must be from 3 to 200 characters.") + return + } + item, ok := s.itemCatalog.Item(req.ItemID) + if !ok || !item.Grantable { + writeError(w, 400, "item_not_grantable", "That item is not in the grant allowlist.") + return + } + if req.Quantity < 1 || req.Quantity > item.MaxQuantity { + writeError(w, 400, "invalid_quantity", fmt.Sprintf("quantity must be from 1 to %d for this item.", item.MaxQuantity)) + return + } + uid := s.store.ResolveUID(r.Context(), chi.URLParam(r, "uid")) + player, err := s.store.PlayerByUID(r.Context(), uid) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, 404, "player_not_found", "Player not found.") + return + } + if err != nil { + internal(w, err) + return + } + if !s.poll.Online()[player.UID] { + writeError(w, 409, "player_offline", "The player must be online to receive an item safely.") + return + } + now := time.Now().UTC() + requestID, err := itemgrant.NewRequestID() + if err != nil { + internal(w, err) + return + } + grant := store.ItemGrant{RequestID: requestID, IdempotencyKey: req.IdempotencyKey, CreatedAt: now, UpdatedAt: now, Actor: principalFrom(r).Username, PlayerUID: player.UID, PlayerName: player.Name, ItemID: item.ID, ItemName: item.Name, Quantity: req.Quantity, Reason: req.Reason, Status: "queued"} + grant, created, err := s.store.CreateItemGrant(r.Context(), grant) + if err != nil { + internal(w, err) + return + } + if !created { + if grant.PlayerUID != player.UID || grant.ItemID != item.ID || grant.Quantity != req.Quantity { + writeError(w, 409, "idempotency_conflict", "That idempotency key was already used for a different grant.") + return + } + grant = s.reconcileItemGrant(r, grant) + writeJSON(w, http.StatusOK, grant) + return + } + bridgeReq := itemgrant.Request{ProtocolVersion: itemgrant.BridgeProtocolVersion, RequestID: requestID, CreatedAt: now.Format(time.RFC3339), PlayerUID: player.UID, PlayerName: player.Name, ItemID: item.ID, Quantity: req.Quantity} + if err := s.itemSpool.Submit(bridgeReq); err != nil { + _ = s.store.CompleteItemGrant(r.Context(), requestID, "failed", 0, "bridge_submit_failed", "The bridge could not accept the request.", now) + if errors.Is(err, itemgrant.ErrBridgeBusy) { + writeError(w, http.StatusConflict, "item_grant_busy", "Another item grant is already being processed; wait for it to finish.") + return + } + internal(w, err) + return + } + s.audit(r, "item_grant", fmt.Sprintf("Queued %d × %s for %s", req.Quantity, item.Name, player.Name), map[string]any{"requestId": requestID, "playerUid": player.UID, "itemId": item.ID, "quantity": req.Quantity}) + writeJSON(w, http.StatusAccepted, grant) +} + +func (s *Server) getItemGrant(w http.ResponseWriter, r *http.Request) { + grant, err := s.store.ItemGrant(r.Context(), chi.URLParam(r, "requestId")) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, 404, "grant_not_found", "Item grant not found.") + return + } + if err != nil { + internal(w, err) + return + } + writeJSON(w, 200, s.reconcileItemGrant(r, grant)) +} + +func (s *Server) listItemGrants(w http.ResponseWriter, r *http.Request) { + grants, err := s.store.ItemGrants(r.Context(), 100) + if err != nil { + internal(w, err) + return + } + for i := range grants { + grants[i] = s.reconcileItemGrant(r, grants[i]) + } + writeJSON(w, 200, map[string]any{"grants": grants}) +} + +func (s *Server) reconcileItemGrant(r *http.Request, grant store.ItemGrant) store.ItemGrant { + if grant.Status != "queued" { + return grant + } + result, err := s.itemSpool.Result(grant.RequestID) + if err != nil { + return grant + } + completedAt := time.Now().UTC() + if parsed, parseErr := time.Parse(time.RFC3339, result.CompletedAt); parseErr == nil && parsed.After(completedAt.Add(-5*time.Minute)) && parsed.Before(completedAt.Add(5*time.Minute)) { + completedAt = parsed + } + if (result.Status == "succeeded" && (result.GrantedQuantity < 1 || result.GrantedQuantity > grant.Quantity)) || (result.Status == "failed" && result.GrantedQuantity != 0) { + _ = s.store.CompleteItemGrant(r.Context(), grant.RequestID, "failed", 0, "invalid_bridge_result", "The bridge returned an invalid quantity; the mutation outcome is unknown and was not retried.", completedAt) + updated, updateErr := s.store.ItemGrant(r.Context(), grant.RequestID) + if updateErr == nil { + return updated + } + return grant + } + _ = s.store.CompleteItemGrant(r.Context(), grant.RequestID, result.Status, result.GrantedQuantity, result.ErrorCode, result.ErrorMessage, completedAt) + updated, err := s.store.ItemGrant(r.Context(), grant.RequestID) + if err == nil { + return updated + } + return grant +} diff --git a/backend/internal/server/item_grants_test.go b/backend/internal/server/item_grants_test.go new file mode 100644 index 0000000..850f357 --- /dev/null +++ b/backend/internal/server/item_grants_test.go @@ -0,0 +1,70 @@ +package server + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/8tp/palhelm/internal/config" + "github.com/8tp/palhelm/internal/store" +) + +func newItemGrantTestServer(t *testing.T) (http.Handler, *store.Store) { + t.Helper() + dir := t.TempDir() + iconDir := filepath.Join(dir, "icons") + if err := os.MkdirAll(iconDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(iconDir, "stone.webp"), []byte("RIFF0000WEBPtest"), 0o600); err != nil { + t.Fatal(err) + } + catalog := `{"schemaVersion":1,"gameVersion":"1.0.1","source":{"name":"test export"},"items":[{"id":"Stone","name":"Stone","category":"Material","icon":"stone.webp","maxQuantity":9999,"grantable":true},{"id":"DebugOnly","name":"Debug Only","maxQuantity":1,"grantable":false}]}` + catalogPath := filepath.Join(dir, "item-catalog.json") + if err := os.WriteFile(catalogPath, []byte(catalog), 0o600); err != nil { + t.Fatal(err) + } + st, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + cfg := config.Config{AdminPassword: "panelpass", ViewerPassword: "viewerpass", SessionSecret: strings.Repeat("s", 48), DataDir: dir, ItemCatalogPath: catalogPath, ItemIconDir: iconDir, ItemGrantSpoolDir: filepath.Join(dir, "spool"), ItemGrantsEnabled: false, IntegrationRateLimit: 60} + _, h := New(cfg, st, testLogger()) + return h, st +} + +func TestItemCatalogIsViewerSafeAndGrantMutationIsFailClosed(t *testing.T) { + h, st := newItemGrantTestServer(t) + if err := st.UpsertLivePlayer(context.Background(), store.Player{UID: "abcdef12000000000000000000000000", Name: "Player"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + viewer := loginAs(t, h, "viewerpass") + admin := loginAs(t, h, "panelpass") + + list := sessionRequest(h, http.MethodGet, "/api/v1/items?q=stone", "", viewer) + if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"id":"Stone"`) || strings.Contains(list.Body.String(), "DebugOnly") { + t.Fatalf("catalog status=%d body=%s", list.Code, list.Body.String()) + } + icon := sessionRequest(h, http.MethodGet, "/api/v1/items/Stone/icon", "", viewer) + if icon.Code != http.StatusOK || icon.Header().Get("Content-Type") != "image/webp" { + t.Fatalf("icon status=%d type=%q", icon.Code, icon.Header().Get("Content-Type")) + } + capability := sessionRequest(h, http.MethodGet, "/api/v1/items/capability", "", viewer) + if capability.Code != http.StatusOK || !strings.Contains(capability.Body.String(), `"ready":false`) || capability.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("capability status=%d headers=%v body=%s", capability.Code, capability.Header(), capability.Body.String()) + } + body := `{"itemId":"Stone","quantity":10,"reason":"test grant","idempotencyKey":"test-key-123"}` + forbidden := sessionRequest(h, http.MethodPost, "/api/v1/players/abcdef12/item-grants", body, viewer) + if forbidden.Code != http.StatusForbidden { + t.Fatalf("viewer mutation status=%d body=%s", forbidden.Code, forbidden.Body.String()) + } + unavailable := sessionRequest(h, http.MethodPost, "/api/v1/players/abcdef12/item-grants", body, admin) + if unavailable.Code != http.StatusServiceUnavailable || !strings.Contains(unavailable.Body.String(), "item_grants_unavailable") { + t.Fatalf("disabled mutation status=%d body=%s", unavailable.Code, unavailable.Body.String()) + } +} diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index dfd161c..a7f6397 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -24,6 +24,12 @@ "/api/v1/players/{uid}/kick": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player kicked"}}}}, "/api/v1/players/{uid}/ban": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player banned"}}}}, "/api/v1/players/{uid}/unban": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player unbanned"}}}}, + "/api/v1/items": {"get": {"parameters": [{"name":"q","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"maximum":10000,"default":0}}], "responses": {"200": {"description":"Viewer-safe operator-installed grant catalogue; only explicitly grantable entries are returned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemCatalogPage"}}}},"400":{"description":"Invalid limit or offset"}}}}, + "/api/v1/items/capability": {"get": {"responses": {"200": {"description":"Fail-closed item bridge/catalogue capability","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemGrantCapability"}}}}}}}, + "/api/v1/items/{itemId}/icon": {"get": {"parameters":[{"name":"itemId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Operator-installed item icon image"},"404":{"description":"Unknown item or no icon installed"}}}}, + "/api/v1/players/{uid}/item-grants": {"post": {"summary":"Queue an allowlisted item grant for one online player (admin only)","parameters":[{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateItemGrantRequest"}}}},"responses":{"200":{"description":"Idempotent replay of an existing request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemGrant"}}}},"202":{"description":"Grant durably queued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemGrant"}}}},"400":{"description":"Invalid or non-grantable item request"},"403":{"description":"Administrator required"},"409":{"description":"Player offline, idempotency conflict, or bridge busy"},"503":{"description":"Catalogue/bridge capability is not ready"}}}}, + "/api/v1/item-grants": {"get": {"summary":"Newest durable item-grant audit records (admin only)","responses":{"200":{"description":"Up to 100 grant records","content":{"application/json":{"schema":{"type":"object","required":["grants"],"properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/ItemGrant"}}}}}}},"403":{"description":"Administrator required"}}}}, + "/api/v1/item-grants/{requestId}": {"get": {"summary":"One durable item-grant audit record (admin only)","parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Current reconciled grant state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemGrant"}}}},"403":{"description":"Administrator required"},"404":{"description":"Grant not found"}}}}, "/api/v1/whitelist": {"get": {"responses": {"200": {"description": "Whitelist"}}}, "put": {"responses": {"200": {"description": "Whitelist replaced"}}}}, "/api/v1/guilds": {"get": {"responses": {"200": {"description": "Real player guilds only: those with at least one placed base and one member matched to a known player. Placeholder groups (solo auto-organizations and other non-guild group types) are excluded from the list, but remain reachable via GET /api/v1/guilds/{id}."}}}}, "/api/v1/guilds/{id}": {"get": {"parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9a-fA-F-]{1,36}$"}}], "responses": {"200": {"description": "Viewer-safe current-save guild detail with members, bases, bounded associated Pals, and current-membership-attributed 30-day panel activity", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildDetail"}}}}, "404": {"description": "Unknown or invalid guild id"}}}}, @@ -218,6 +224,11 @@ "PlayerPaldeck": {"type":"object","required":["player","coverage","catalog","captureTotal","uniquePalsCaptured","paldeckUnlocked","species"],"properties":{"player":{"type":"object","required":["uid","name"],"properties":{"uid":{"type":"string"},"name":{"type":"string"}}},"coverage":{"$ref":"#/components/schemas/PlayerPaldeckCoverage"},"catalog":{"$ref":"#/components/schemas/PaldeckCatalog"},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"species":{"type":"array","maxItems":4096,"items":{"$ref":"#/components/schemas/PlayerPaldeckSpecies"}}}}, "GuildDetail": {"type":"object","required":["id","name","adminUid","memberCount","members","bases","palCount","palsTruncated","pals","activity"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"adminUid":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"members":{"type":"array","items":{"type":"object","required":["uid","name","level","online","lastSeenAt","playtimeSec","captureTotal","uniquePalsCaptured","paldeckUnlocked","observedDurationSec","observedSessionCount","currentSession"],"properties":{"uid":{"type":"string"},"name":{"type":"string"},"level":{"type":"integer"},"online":{"type":"boolean"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"playtimeSec":{"type":"integer","format":"int64","minimum":0},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"observedDurationSec":{"type":"integer","format":"int64","minimum":0},"observedSessionCount":{"type":"integer","minimum":0},"currentSession":{"type":"boolean"}}}},"bases":{"type":"array","items":{"type":"object","required":["id","name","location","level","palCount"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"location":{"oneOf":[{"$ref":"#/components/schemas/IntegrationLocation"},{"type":"null"}]},"level":{"type":"integer"},"palCount":{"type":"integer","minimum":0}}}},"palCount":{"type":"integer","minimum":0},"palsTruncated":{"type":"boolean"},"pals":{"type":"array","maxItems":500,"items":{"type":"object","required":["instanceId","characterId","displayName","level","rank","isAlpha","isLucky","isBoss","placement","baseId","ownerUid","ownerName","ownerSource","ownerResolved","association"],"properties":{"instanceId":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"level":{"type":"integer"},"rank":{"type":["integer","null"],"minimum":1,"maximum":5,"description":"Pal Condenser rank: 1 (never condensed) through 5 (four stars). null when the save carried no Rank property; never inferred as 0."},"isAlpha":{"type":"boolean"},"isLucky":{"type":"boolean"},"isBoss":{"type":"boolean"},"placement":{"type":"string","enum":["party","box","base","unknown"]},"baseId":{"type":["string","null"]},"ownerUid":{"type":"string"},"ownerName":{"type":"string"},"ownerSource":{"type":"string","enum":["save","personal_container","last_observed","unresolved"]},"ownerResolved":{"type":"boolean"},"association":{"type":"string","enum":["guild_base","current_member_owner"]}}}},"activity":{"type":"object","required":["coverage","attribution","window","since","through","trackingSince","analysisTruncated","durationSec","sessionCount","activePlayers"],"properties":{"coverage":{"type":"string","const":"panel_observed_sessions"},"attribution":{"type":"string","const":"current_guild_membership"},"window":{"type":"string","const":"30d"},"since":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"trackingSince":{"type":["string","null"],"format":"date-time"},"analysisTruncated":{"type":"boolean"},"durationSec":{"type":"integer","format":"int64","minimum":0},"sessionCount":{"type":"integer","minimum":0},"activePlayers":{"type":"integer","minimum":0}}}}}, "ServerInfo": {"type": "object", "required": ["name", "description", "version", "worldGuid", "state", "uptimeSec", "panelVersion", "sessionDays", "saveSyncMinutes"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "version": {"type": "string"}, "worldGuid": {"type": "string"}, "state": {"type": "string"}, "uptimeSec": {"type": "integer", "format": "int64"}, "panelVersion": {"type": "string"}, "sessionDays": {"type": "integer", "minimum": 1, "description": "Login session lifetime in whole days (PALHELM_SESSION_DAYS)."}, "saveSyncMinutes": {"type": "integer", "minimum": 0, "description": "Save-sync poll interval in whole minutes (PALHELM_SAVE_SYNC_INTERVAL)."}}}, + "GrantableItem": {"type":"object","required":["id","name","maxQuantity","grantable"],"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9_]{1,160}$"},"name":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"rarity":{"type":"string"},"icon":{"type":"string"},"maxQuantity":{"type":"integer","minimum":1,"maximum":9999},"grantable":{"type":"boolean","const":true}}}, + "ItemCatalogPage": {"type":"object","required":["items","total","gameVersion","source"],"properties":{"items":{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/GrantableItem"}},"total":{"type":"integer","minimum":0},"gameVersion":{"type":"string"},"source":{"type":["object","null"],"properties":{"name":{"type":"string"},"url":{"type":"string"},"generatedAt":{"type":"string"}}}}}, + "ItemGrantCapability": {"type":"object","required":["enabled","ready","reason","protocolVersion","catalogItems"],"properties":{"enabled":{"type":"boolean"},"ready":{"type":"boolean"},"reason":{"type":"string"},"protocolVersion":{"type":"integer"},"gameVersion":{"type":"string"},"catalogVersion":{"type":"string"},"catalogItems":{"type":"integer","minimum":0},"lastHeartbeatAt":{"type":"string","format":"date-time"}}}, + "CreateItemGrantRequest": {"type":"object","additionalProperties":false,"required":["itemId","quantity","reason","idempotencyKey"],"properties":{"itemId":{"type":"string"},"quantity":{"type":"integer","minimum":1,"maximum":9999},"reason":{"type":"string","minLength":3,"maxLength":200},"idempotencyKey":{"type":"string","minLength":8,"maxLength":128,"pattern":"^[A-Za-z0-9._:-]+$"}}}, + "ItemGrant": {"type":"object","required":["requestId","createdAt","updatedAt","actor","playerUid","playerName","itemId","itemName","quantity","reason","status"],"properties":{"requestId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"actor":{"type":"string"},"playerUid":{"type":"string"},"playerName":{"type":"string"},"itemId":{"type":"string"},"itemName":{"type":"string"},"quantity":{"type":"integer","minimum":1},"grantedQuantity":{"type":["integer","null"],"minimum":0},"reason":{"type":"string"},"status":{"type":"string","enum":["queued","succeeded","failed"]},"errorCode":{"type":"string"},"errorMessage":{"type":"string"}}}, "BackupStorage": {"type": "object", "required": ["totalBytes", "freeBytes"], "properties": {"totalBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Total capacity of the backup filesystem, or null when unavailable."}, "freeBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Free space on the backup filesystem, or null when unavailable."}}}, "Backup": {"type": "object", "required": ["id", "file", "createdAt", "sizeBytes", "trigger"], "properties": {"id": {"type": "integer", "format": "int64"}, "file": {"type": "string"}, "createdAt": {"type": "string", "format": "date-time"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "trigger": {"type": "string", "enum": ["scheduled", "manual", "pre-restore", "imported"]}, "worldDay": {"type": "integer", "format": "int64"}}}, "BackupEntry": {"type": "object", "required": ["path", "sizeBytes", "modifiedAt"], "properties": {"path": {"type": "string"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "modifiedAt": {"type": "string", "format": "date-time"}}}, diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 4864776..f09b24e 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -19,6 +19,7 @@ import ( "github.com/8tp/palhelm/internal/backup" "github.com/8tp/palhelm/internal/config" "github.com/8tp/palhelm/internal/gameconfig" + "github.com/8tp/palhelm/internal/itemgrant" "github.com/8tp/palhelm/internal/palworld" "github.com/8tp/palhelm/internal/poller" "github.com/8tp/palhelm/internal/steamavatar" @@ -45,6 +46,8 @@ type Server struct { gamecfg *gameconfig.Editor integration *integrationAuth avatars *steamavatar.Resolver + itemCatalog *itemgrant.Catalog + itemSpool itemgrant.Spool diskStat diskStatFunc started time.Time log *slog.Logger @@ -68,7 +71,17 @@ func New(cfg config.Config, st *store.Store, log *slog.Logger) (*Server, http.Ha // than taking down session/admin routes too. log.Error("load active integration API keys", "error", err) } - s := &Server{cfg: cfg, store: st, pal: pal, rcon: palworld.NewRCONClient(cfg.RCONAddr, cfg.PalworldPassword), poll: p, health: health, hub: hub, auth: newAuth(cfg.SessionSecret, cfg.AdminPassword, cfg.ViewerPassword, cfg.TrustedProxies...), shutdown: newOrchestrator(pal), integration: newIntegrationAuth(st, activeKeys, cfg.IntegrationRateLimit, log), avatars: steamavatar.New(cfg.SteamWebAPIKey), diskStat: statfsDiskUsage, started: time.Now(), log: log} + s := &Server{cfg: cfg, store: st, pal: pal, rcon: palworld.NewRCONClient(cfg.RCONAddr, cfg.PalworldPassword), poll: p, health: health, hub: hub, auth: newAuth(cfg.SessionSecret, cfg.AdminPassword, cfg.ViewerPassword, cfg.TrustedProxies...), shutdown: newOrchestrator(pal), integration: newIntegrationAuth(st, activeKeys, cfg.IntegrationRateLimit, log), avatars: steamavatar.New(cfg.SteamWebAPIKey), itemSpool: itemgrant.Spool{Dir: cfg.ItemGrantSpoolDir}, diskStat: statfsDiskUsage, started: time.Now(), log: log} + if catalog, catalogErr := itemgrant.LoadCatalog(cfg.ItemCatalogPath); catalogErr != nil { + if cfg.ItemGrantsEnabled { + log.Error("item grants unavailable: catalogue failed validation", "error", catalogErr, "path", cfg.ItemCatalogPath) + } else if !errors.Is(catalogErr, os.ErrNotExist) { + log.Warn("optional item catalogue failed validation", "error", catalogErr, "path", cfg.ItemCatalogPath) + } + } else { + s.itemCatalog = catalog + log.Info("item catalogue loaded", "gameVersion", catalog.GameVersion(), "items", catalog.Count()) + } emitBackup := func(message string, meta any) { e := store.Event{At: time.Now().UTC(), Kind: "backup", Message: message, Meta: meta} _ = st.AddEvent(context.Background(), e) @@ -191,6 +204,9 @@ func (s *Server) routes() http.Handler { api.Get("/map/dataset", s.mapDataset) api.Get("/paldeck/icon/{characterId}", s.paldeckIcon) api.Get("/paldeck/icon-dataset", s.paldeckIconDataset) + api.Get("/items", s.items) + api.Get("/items/capability", s.itemGrantCapability) + api.Get("/items/{itemId}/icon", s.itemIcon) api.Get("/console/log", s.consoleLog) api.Get("/console/saved", s.savedCommands) api.Get("/events", s.events) @@ -212,6 +228,9 @@ func (s *Server) routes() http.Handler { m.Post("/players/{uid}/kick", s.kick) m.Post("/players/{uid}/ban", s.ban) m.Post("/players/{uid}/unban", s.unban) + m.Post("/players/{uid}/item-grants", s.createItemGrant) + m.Get("/item-grants", s.listItemGrants) + m.Get("/item-grants/{requestId}", s.getItemGrant) m.Put("/whitelist", s.putWhitelist) m.Post("/world/parse", s.parseWorld) m.Post("/console/exec", s.consoleExec) @@ -241,7 +260,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") - if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") || r.URL.Path == "/api/v1/config" || r.URL.Path == "/api/v1/config/raw" || r.URL.Path == "/api/v1/world/snapshot" || strings.HasPrefix(r.URL.Path, "/api/v1/integration-keys") { + if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") || r.URL.Path == "/api/v1/config" || r.URL.Path == "/api/v1/config/raw" || r.URL.Path == "/api/v1/world/snapshot" || r.URL.Path == "/api/v1/items/capability" || strings.HasPrefix(r.URL.Path, "/api/v1/item-grants") || strings.HasPrefix(r.URL.Path, "/api/v1/integration-keys") { w.Header().Set("Cache-Control", "no-store") } next.ServeHTTP(w, r) diff --git a/backend/internal/store/item_grants.go b/backend/internal/store/item_grants.go new file mode 100644 index 0000000..fb3ae6f --- /dev/null +++ b/backend/internal/store/item_grants.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +type ItemGrant struct { + RequestID string `json:"requestId"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Actor string `json:"actor"` + PlayerUID string `json:"playerUid"` + PlayerName string `json:"playerName"` + ItemID string `json:"itemId"` + ItemName string `json:"itemName"` + Quantity int `json:"quantity"` + GrantedQuantity *int `json:"grantedQuantity"` + Reason string `json:"reason"` + Status string `json:"status"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +func (s *Store) CreateItemGrant(ctx context.Context, g ItemGrant) (ItemGrant, bool, error) { + g.PlayerUID = NormalizeUID(g.PlayerUID) + _, err := s.db.ExecContext(ctx, `INSERT INTO item_grants(request_id,idempotency_key,created_at,updated_at,actor,player_uid,player_name,item_id,item_name,quantity,reason,status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + g.RequestID, g.IdempotencyKey, g.CreatedAt.Unix(), g.UpdatedAt.Unix(), g.Actor, g.PlayerUID, g.PlayerName, g.ItemID, g.ItemName, g.Quantity, g.Reason, g.Status) + if err == nil { + return g, true, nil + } + var existing ItemGrant + existing, getErr := s.ItemGrantByIdempotencyKey(ctx, g.IdempotencyKey) + if getErr == nil { + return existing, false, nil + } + return ItemGrant{}, false, err +} + +func (s *Store) ItemGrantByIdempotencyKey(ctx context.Context, key string) (ItemGrant, error) { + return scanItemGrant(s.db.QueryRowContext(ctx, itemGrantSelect+" WHERE idempotency_key=?", key)) +} + +func (s *Store) ItemGrant(ctx context.Context, requestID string) (ItemGrant, error) { + return scanItemGrant(s.db.QueryRowContext(ctx, itemGrantSelect+" WHERE request_id=?", requestID)) +} + +func (s *Store) ItemGrants(ctx context.Context, limit int) ([]ItemGrant, error) { + if limit < 1 || limit > 200 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, itemGrantSelect+" ORDER BY created_at DESC LIMIT ?", limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]ItemGrant, 0) + for rows.Next() { + g, err := scanItemGrant(rows) + if err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +func (s *Store) CompleteItemGrant(ctx context.Context, requestID, status string, granted int, code, message string, now time.Time) error { + if status != "succeeded" && status != "failed" { + return errors.New("invalid item grant status") + } + var quantity any + if status == "succeeded" { + quantity = granted + } + result, err := s.db.ExecContext(ctx, `UPDATE item_grants SET updated_at=?,status=?,granted_quantity=?,error_code=?,error_message=? WHERE request_id=? AND status='queued'`, now.Unix(), status, quantity, code, message, requestID) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + _, lookupErr := s.ItemGrant(ctx, requestID) + return lookupErr + } + return nil +} + +const itemGrantSelect = `SELECT request_id,idempotency_key,created_at,updated_at,actor,player_uid,player_name,item_id,item_name,quantity,granted_quantity,reason,status,error_code,error_message FROM item_grants` + +type itemGrantScanner interface{ Scan(dest ...any) error } + +func scanItemGrant(row itemGrantScanner) (ItemGrant, error) { + var g ItemGrant + var created, updated int64 + var granted sql.NullInt64 + err := row.Scan(&g.RequestID, &g.IdempotencyKey, &created, &updated, &g.Actor, &g.PlayerUID, &g.PlayerName, &g.ItemID, &g.ItemName, &g.Quantity, &granted, &g.Reason, &g.Status, &g.ErrorCode, &g.ErrorMessage) + if err != nil { + return ItemGrant{}, err + } + g.CreatedAt, g.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + if granted.Valid { + v := int(granted.Int64) + g.GrantedQuantity = &v + } + return g, nil +} diff --git a/backend/internal/store/item_grants_test.go b/backend/internal/store/item_grants_test.go new file mode 100644 index 0000000..b81ab0a --- /dev/null +++ b/backend/internal/store/item_grants_test.go @@ -0,0 +1,39 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func TestItemGrantAuditIsIdempotentAndFirstResultWins(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + want := ItemGrant{RequestID: "request123", IdempotencyKey: "panel-idem-123", CreatedAt: now, UpdatedAt: now, Actor: "admin", PlayerUID: "ABCDEF12", PlayerName: "Player", ItemID: "Stone", ItemName: "Stone", Quantity: 25, Reason: "replace lost materials", Status: "queued"} + created, inserted, err := st.CreateItemGrant(ctx, want) + if err != nil || !inserted || created.PlayerUID != "abcdef12" { + t.Fatalf("create = %#v inserted=%v err=%v", created, inserted, err) + } + replayed := want + replayed.RequestID = "different-request" + got, inserted, err := st.CreateItemGrant(ctx, replayed) + if err != nil || inserted || got.RequestID != want.RequestID { + t.Fatalf("replay = %#v inserted=%v err=%v", got, inserted, err) + } + if err := st.CompleteItemGrant(ctx, want.RequestID, "succeeded", 25, "", "", now.Add(time.Second)); err != nil { + t.Fatal(err) + } + if err := st.CompleteItemGrant(ctx, want.RequestID, "failed", 0, "late", "late result", now.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + got, err = st.ItemGrant(ctx, want.RequestID) + if err != nil || got.Status != "succeeded" || got.GrantedQuantity == nil || *got.GrantedQuantity != 25 { + t.Fatalf("completed grant = %#v err=%v", got, err) + } +} diff --git a/backend/internal/store/migrations/013_item_grants.sql b/backend/internal/store/migrations/013_item_grants.sql new file mode 100644 index 0000000..97c58b3 --- /dev/null +++ b/backend/internal/store/migrations/013_item_grants.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS item_grants ( + request_id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + actor TEXT NOT NULL, + player_uid TEXT NOT NULL, + player_name TEXT NOT NULL, + item_id TEXT NOT NULL, + item_name TEXT NOT NULL, + quantity INTEGER NOT NULL CHECK(quantity >= 1 AND quantity <= 9999), + granted_quantity INTEGER, + reason TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('queued','succeeded','failed')), + error_code TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS item_grants_created_at ON item_grants(created_at DESC); +CREATE INDEX IF NOT EXISTS item_grants_player_uid ON item_grants(player_uid, created_at DESC); diff --git a/backend/internal/store/store_migration_audit_test.go b/backend/internal/store/store_migration_audit_test.go index 8b11ccb..e34e4b7 100644 --- a/backend/internal/store/store_migration_audit_test.go +++ b/backend/internal/store/store_migration_audit_test.go @@ -132,8 +132,8 @@ func TestAuditUpgradeV030RealVolumeReadableThroughBothSurfaces(t *testing.T) { } defer st.Close() ctx := context.Background() - if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "12" { - t.Fatalf("schema_version = %q, %v; want 12", v, err) + if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "13" { + t.Fatalf("schema_version = %q, %v; want 13", v, err) } if v, err := st.GetKV(ctx, "operator-note"); err != nil || v != "preserve-me" { t.Fatalf("operator kv = %q, %v", v, err) @@ -293,8 +293,8 @@ func TestAuditInterruptedMigrationReplayPreservesData(t *testing.T) { t.Fatalf("reopen after simulated interrupted migration: %v", err) } defer reopened.Close() - if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "12" { - t.Fatalf("schema_version after replay = %q, %v; want repaired to 12", v, err) + if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "13" { + t.Fatalf("schema_version after replay = %q, %v; want repaired to 13", v, err) } keys, err := reopened.ListAPIKeys(ctx) if err != nil || len(keys) != 1 || keys[0].ID != "aaaa1111" || keys[0].Label != "survives-replay" { @@ -310,7 +310,7 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { t.Run("future version names both numbers", func(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") legacy := buildV030Database(t, path) - if _, err := legacy.Exec(`UPDATE kv SET value='13' WHERE key='schema_version'`); err != nil { + if _, err := legacy.Exec(`UPDATE kv SET value='14' WHERE key='schema_version'`); err != nil { t.Fatal(err) } if err := legacy.Close(); err != nil { @@ -318,9 +318,9 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { } _, err := Open(path) if err == nil { - t.Fatal("Open on schema_version 13 succeeded") + t.Fatal("Open on schema_version 14 succeeded") } - for _, needle := range []string{"13", "12", "newer than this binary supports"} { + for _, needle := range []string{"14", "13", "newer than this binary supports"} { if !strings.Contains(err.Error(), needle) { t.Errorf("fail-closed error %q does not mention %q", err, needle) } @@ -403,9 +403,9 @@ func TestAuditConcurrentOpenSameFile(t *testing.T) { if err != nil { t.Fatalf("round %d: reopen after concurrent Open: %v", round, err) } - if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "12" { + if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "13" { st.Close() - t.Fatalf("round %d: schema_version = %q, %v; want 12", round, v, err) + t.Fatalf("round %d: schema_version = %q, %v; want 13", round, v, err) } if _, err := st.ListAPIKeys(context.Background()); err != nil { st.Close() @@ -527,7 +527,7 @@ func TestAuditDowngradeV04DatabaseUnderV03OpenSemantics(t *testing.T) { t.Fatalf("v0.3 binary cannot open a v0.4 database (001 re-execution failed): %v", err) } var v string - if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "12" { + if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "13" { t.Fatalf("schema_version after v0.3-style open = %q, %v; INSERT OR IGNORE must not clobber it", v, err) } var name string diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 2956568..b89d84d 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -612,8 +612,8 @@ func TestMigration004AddsNullablePalPlacementColumns(t *testing.T) { t.Fatal(err) } defer st.Close() - if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "12" { - t.Fatalf("schema_version = %q, %v; want 12", v, getErr) + if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "13" { + t.Fatalf("schema_version = %q, %v; want 13", v, getErr) } pals, err := st.PalsTyped(context.Background(), "owner") if err != nil || len(pals) != 1 { @@ -672,8 +672,8 @@ func TestFreshDatabaseReachesLatestSchemaAndAPIKeysUsable(t *testing.T) { defer s.Close() ctx := context.Background() v, err := s.GetKV(ctx, "schema_version") - if err != nil || v != "12" { - t.Fatalf("schema_version = %q, %v; want 12", v, err) + if err != nil || v != "13" { + t.Fatalf("schema_version = %q, %v; want 13", v, err) } hash := [32]byte{1, 2, 3} created, err := s.CreateAPIKey(ctx, "abcd1234", hash, "fresh-db-key", time.Now()) @@ -739,8 +739,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { ctx := context.Background() v, err := upgraded.GetKV(ctx, "schema_version") - if err != nil || v != "12" { - t.Fatalf("schema_version after upgrade = %q, %v; want 12", v, err) + if err != nil || v != "13" { + t.Fatalf("schema_version after upgrade = %q, %v; want 13", v, err) } if _, err = upgraded.ListAPIKeys(ctx); err != nil { t.Fatalf("api_keys table not usable after upgrade: %v", err) @@ -769,8 +769,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { t.Fatalf("second Open (already at latest version) returned an error: %v", err) } defer reopened.Close() - if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "12" { - t.Fatalf("schema_version after no-op reopen = %q, %v; want 12", v, err) + if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "13" { + t.Fatalf("schema_version after no-op reopen = %q, %v; want 13", v, err) } } diff --git a/docs-site/src/content/docs/architecture/data-channels.md b/docs-site/src/content/docs/architecture/data-channels.md index 50cd97b..01d2b7c 100644 --- a/docs-site/src/content/docs/architecture/data-channels.md +++ b/docs-site/src/content/docs/architecture/data-channels.md @@ -1,19 +1,21 @@ --- title: Data channels -description: The three ways Palhelm reads and controls a Palworld server, and which feature uses which. +description: The core channels Palhelm uses to read and control a server, plus the optional item-grant bridge. sidebar: order: 2 --- -This page covers the three channels Palhelm uses to talk to a Palworld dedicated -server: the official REST API, RCON, and the read-only save files. It also maps each +This page covers the three core channels Palhelm uses to talk to a Palworld dedicated +server: the official REST API, RCON, and the read-only save files. It also covers the +optional, local-only UE4SS item-grant bridge and maps each Palhelm feature to the channel it draws from, and explains the read-only guarantee on save files. -## The three channels +## The core channels Palhelm never runs game logic itself. Everything it shows or does comes through one of -three channels into the running server. +these core channels into the running server. Item grants are the one optional +exception and use the separate bridge described below. ### 1. Official REST API @@ -62,29 +64,44 @@ save-sync interval and on demand to populate offline players, pals, guilds, and The `Players/` directory does not exist until the first player joins. Palhelm treats that as a normal state, not an error. +### 4. Optional UE4SS item bridge + +Item grants do not fit the vanilla REST, RCON, or save-file channels. When an +operator explicitly installs and enables the feature, Palhelm and a small UE4SS +server bridge exchange versioned request, result, and heartbeat files through a +local-only spool. The bridge mutates the online player's authoritative inventory; +the save parser remains read-only. + +This channel is disabled by default and fails closed. A ready capability requires +a matching item catalogue and allowlist, a fresh heartbeat, the expected protocol, +and an exact game build marked production-validated by the bridge. Requests use +stable player and item IDs, bounded quantities, required reasons, idempotency, and +a durable audit ledger. Palhelm does not retry an uncertain mutation. + ## Read-only guarantee on saves Palhelm's save parser is decode only. It reads bytes and builds typed structs; it never re-encodes or writes a save file. In-place save editing, such as giving items or pals, -is an explicit non-goal. The only component that writes into the save directory area is +is not used for item grants. The only component that writes into the save directory area is the backup engine, which copies and archives save files and, on restore, swaps them in through a guided flow that requires the server to be stopped. Parsing itself only reads. ## Which feature uses which channel -| Feature | REST API | RCON | Save files | -|---|---|---|---| -| Dashboard metrics and charts | Yes | | | -| Live player list and positions | Yes | | | -| Optional live Pal/base activity | Yes | | | -| Announce, kick, ban, unban | Yes | | | -| Save now | Yes | | | -| Graceful shutdown and stop | Yes | | | -| Console screen | | Yes | | -| Teleport and other RCON-only actions | | Yes | | -| Offline players, pals, guilds, bases | | | Yes | -| Live map markers | Yes | | Yes | -| Backups and restore | Yes | | Reads and copies files | +| Feature | REST API | RCON | Save files | Optional UE4SS bridge | +|---|---|---|---|---| +| Dashboard metrics and charts | Yes | | | | +| Live player list and positions | Yes | | | | +| Optional live Pal/base activity | Yes | | | | +| Announce, kick, ban, unban | Yes | | | | +| Save now | Yes | | | | +| Graceful shutdown and stop | Yes | | | | +| Console screen | | Yes | | | +| Teleport and other RCON-only actions | | Yes | | | +| Offline players, pals, guilds, bases | | | Yes | | +| Live map markers | Yes | | Yes | | +| Backups and restore | Yes | | Reads and copies files | | +| Allowlisted item grant | | | | Yes, when validated | The live map is the one screen that blends channels. Live player positions come from the REST API, while guild bases and other placement data come from the parsed save. The diff --git a/docs-site/src/content/docs/architecture/security-model.md b/docs-site/src/content/docs/architecture/security-model.md index fae8eb7..051f054 100644 --- a/docs-site/src/content/docs/architecture/security-model.md +++ b/docs-site/src/content/docs/architecture/security-model.md @@ -42,6 +42,10 @@ viewer does not see destructive controls, but the enforcement is server-side. A who crafts a request by hand still cannot perform an admin action. The UI adapting is a convenience, not the control. +Item-grant creation and audit endpoints are admin-only. Their capability and catalogue +views reveal only bounded operational metadata; no Integration API key can invoke or +inspect a grant. + ## Two API surfaces that never cross Palhelm has two HTTP surfaces, and they are kept apart structurally, not just by policy: @@ -94,6 +98,20 @@ Two operator features touch the host, and both fail closed: operator to run the printed command from the host; Palhelm does not run Docker Compose itself. +## Optional item-grant bridge + +The UE4SS item bridge is a separate, disabled-by-default mutation surface. Palhelm +accepts only stable player IDs and operator-allowlisted item IDs, enforces bounded +quantities and a required reason, writes a durable audit row before dispatch, and +uses idempotency keys to prevent duplicate delivery. It exposes no arbitrary Lua, +shell, RCON, asset path, or command input. + +The provider reports unavailable unless its configured catalogue, generated bridge +allowlist, protocol version, game version, heartbeat freshness, and exact-build +production-validation marker agree. A missing result is an uncertain outcome, not +permission to retry. The bridge mutates live authoritative inventory and never edits +the world save. + ## Honest threat model What Palhelm defends against: @@ -111,7 +129,9 @@ What Palhelm does not defend against, and does not claim to: - Exposure to the open internet. The trusted-edge assumption is load-bearing. - A compromised admin session. An attacker with a live admin session can do what an admin - can do, including using the config editor as a write primitive against the Compose file. + can do, including using the config editor as a write primitive against the Compose file + and, when an operator has separately enabled a validated item bridge, granting allowlisted + items within its limits. - The security of the game server itself, or of the host it runs on. Those are the operator's responsibility. diff --git a/docs-site/src/content/docs/getting-started/what-is-palhelm.md b/docs-site/src/content/docs/getting-started/what-is-palhelm.md index de63f70..cc134c8 100644 --- a/docs-site/src/content/docs/getting-started/what-is-palhelm.md +++ b/docs-site/src/content/docs/getting-started/what-is-palhelm.md @@ -9,7 +9,7 @@ This page covers what Palhelm is, the two parts you can run, and the limits it i Palhelm is a self-hosted web admin panel for Palworld dedicated servers. It ships as one Docker image that runs one process with no external database. You run it next to the game server you already have, and you manage the server from a web browser on your own network. -Palhelm targets Palworld 1.0. It talks to the server over three channels: the official REST API, RCON, and the on-disk save files. The save reader is a pure-Go parser for the 1.0 Oodle-compressed `Level.sav` format. +Palhelm targets Palworld 1.0. Its three core channels are the official REST API, RCON, and the on-disk save files. The save reader is a pure-Go parser for the 1.0 Oodle-compressed `Level.sav` format. An optional fourth channel, a local UE4SS bridge, can perform tightly bounded item grants when an operator explicitly installs, validates, and enables it. ## The two parts @@ -27,6 +27,7 @@ Palhelm has two parts. You can run the panel alone, or add the bot later. - Takes scheduled and manual backups, and restores a snapshot after a dry-run diff and a typed confirmation. - Edits your Compose file's `environment:` block for server settings, then shows you the exact host command to apply the change. - Offers an admin login and an optional read-only viewer login. +- Optionally queues allowlisted item grants for online players through a separate, audited UE4SS bridge. The bridge is disabled by default and never edits saves. ## What it does not do @@ -37,6 +38,7 @@ Palhelm is deliberately honest about its edges. - **It does not work around vanilla RCON limits.** Vanilla RCON has no whisper, and `Broadcast` mangles spaces. Palhelm prefers the REST API for moderation and says so in the UI. - **It does not apply Docker changes for you.** One-click apply is intentionally disabled, because a container cannot safely preserve arbitrary host project paths. Palhelm prints the command; you run it on the host. - **It does not degrade silently on a game update.** If a future patch drifts the save format, the affected feature shows a "format drift" badge instead of showing wrong data. +- **It does not assume an item bridge is safe on a new game build.** Item grants stay unavailable until the operator-installed catalogue, heartbeat, protocol, and exact-build validation marker all agree. The source bridge ships fail-closed. ## Next steps diff --git a/docs-site/src/content/docs/panel/item-grants.md b/docs-site/src/content/docs/panel/item-grants.md new file mode 100644 index 0000000..4a696fb --- /dev/null +++ b/docs-site/src/content/docs/panel/item-grants.md @@ -0,0 +1,61 @@ +--- +title: Item grants +description: The optional, fail-closed UE4SS item bridge, operator prerequisites, admin flow, and audit trail. +sidebar: + order: 3 +--- + +Item grants are an optional admin-only feature for giving a bounded quantity of an +allowlisted item to one online player. They use a separate UE4SS server bridge and +do not edit Palworld save files. + +:::caution +The source bridge ships with `ProductionValidated=false`. Installing the files is +not enough to make grants available. Validate the exact Palworld and UE4SS build in +a maintenance window before changing that gate. +::: + +## Prerequisites + +The capability reports ready only when all of these conditions are met: + +1. `PALHELM_ITEM_GRANTS_ENABLED=true` explicitly enables the provider. +2. `PALHELM_ITEM_CATALOG_PATH` points to a validated, versioned operator catalogue. +3. The UE4SS bridge has a generated allowlist from the same manifest, including + per-item quantity ceilings. +4. `PALHELM_ITEM_GRANT_SPOOL_DIR` is a local directory shared only by Palhelm and + the game-server bridge. +5. A fresh heartbeat matches the expected protocol, catalogue, and game version. +6. The bridge marks that exact game build as production-validated after the + maintenance checklist succeeds. + +The repository includes `item-catalog-import` and an example manifest for creating +the catalogue, icons, and bridge allowlist from operator-provided game data. Palhelm +does not distribute game artwork. + +## Admin flow + +From an online player's detail view, select **Give item…**, search the installed +catalogue, choose a quantity, and enter a reason. The review step shows the exact +stable player UID, internal item ID, quantity, and current capability before the +request can be confirmed. + +Palhelm writes the audit record before moving one atomic request file into the +spool. The bridge validates it again, performs the live inventory mutation, and +writes one result. The player must be online. Display names are never used as the +mutation target. + +## Safety and audit behavior + +- Only admins can create or inspect grants; viewers cannot see the action. +- The Integration API remains GET-only and has no item-grant surface. +- Arbitrary item IDs, commands, Lua, shell input, and asset paths are rejected. +- Requests are serialized, quantity-capped, reasoned, and idempotent. +- Successes and failures retain actor, target, item, quantity, reason, timestamps, + and the bounded bridge result. +- A missing or late result is never retried automatically because the original + mutation may already have succeeded. +- Disabled, stale, mismatched, or unvalidated bridges keep the action unavailable. + +The exact-build validation and rollback checklist lives in the repository's +`docs/SERVER-MOD-INTEGRATIONS-PLAN.md`. diff --git a/docs-site/src/content/docs/panel/players.md b/docs-site/src/content/docs/panel/players.md index ddd1912..592b153 100644 --- a/docs-site/src/content/docs/panel/players.md +++ b/docs-site/src/content/docs/panel/players.md @@ -41,6 +41,30 @@ Each action opens a confirm dialog first. Kick and ban let you type an optional A viewer has read-only access. Viewers see the list and the detail panel, but none of the moderation actions. ::: +### Optional item grants + +When the item-grant provider reports ready, an admin can select **Give item…** for +an online player. The dialog searches the operator-installed allowlist, applies +the per-item quantity ceiling, requires a reason, and shows a final confirmation. +Every accepted request receives an idempotency key and a durable audit record. + +This action does not edit the world save and does not run through RCON. Palhelm +writes a bounded request to a local spool shared with the optional UE4SS bridge; +the bridge performs the authoritative in-game inventory mutation and writes one +result. Palhelm never automatically retries an uncertain result. + +The action fails closed and cannot be submitted unless all of these are true: + +- `PALHELM_ITEM_GRANTS_ENABLED=true` was set explicitly. +- The installed item catalogue and bridge allowlist match and pass validation. +- A fresh heartbeat reports the expected protocol and game version. +- The bridge package marks that exact game build as production-validated. + +The source bridge currently ships with production validation disabled. Operators +must complete the maintenance-window checklist for their exact game build before +enabling it. Item-grant endpoints are admin-only and are intentionally absent from +the read-only Integration API. + ### The Pal box The detail panel shows the player's active party first, up to five Pals from the save. Each entry has an info button that expands to show level, individual HP, gender, condenser star rating, talents, passive skill ids, and equipped skill ids, plus alpha and lucky markers. A never-condensed Pal shows an empty four-star row (the game omits the rank until a Pal is condensed); **Unavailable** appears only for data parsed before rank decoding shipped. It also joins the Pal's CharacterID to the bundled, version-pinned species catalogue and shows every available work suitability as a labeled SVG badge with its numeric level, such as **Handiwork Lv 4**. Save observations and species metadata remain labeled separately. @@ -75,4 +99,4 @@ Lists the players you have banned so a ban can be reviewed or lifted. Each row h ## Data sources -This screen reads `GET /api/v1/players`, `GET /api/v1/players/{uid}` for detail, `GET /api/v1/guilds`, and `GET/PUT /api/v1/whitelist` for player notes. Moderation uses `POST /api/v1/players/{uid}/kick`, `/ban`, and `/unban`. Messaging uses `POST /api/v1/server/announce`. +This screen reads `GET /api/v1/players`, `GET /api/v1/players/{uid}` for detail, `GET /api/v1/guilds`, and `GET/PUT /api/v1/whitelist` for player notes. Moderation uses `POST /api/v1/players/{uid}/kick`, `/ban`, and `/unban`. Messaging uses `POST /api/v1/server/announce`. Optional item grants use `GET /api/v1/items`, `GET /api/v1/items/capability`, `POST /api/v1/players/{uid}/item-grants`, and the admin-only `/api/v1/item-grants` audit endpoints. diff --git a/docs/API.md b/docs/API.md index 7aa3669..4d3748f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -35,6 +35,12 @@ Operation-specific recovery details, such as Config's `manualCommand`, stay insi | POST | `/players/{uid}/kick` | `{message?}` | | POST | `/players/{uid}/ban` | `{message?}` | | POST | `/players/{uid}/unban` | | +| GET | `/items?q=&limit=50&offset=0` | Viewer-safe, paginated operator-installed grant catalogue. Returns only entries explicitly marked `grantable`; at most 100 rows. | +| GET | `/items/capability` | Fail-closed provider state: `{enabled, ready, reason, protocolVersion, gameVersion?, catalogVersion?, catalogItems, lastHeartbeatAt?}`. `ready` requires explicit enablement, a validated matching catalogue/allowlist, and a fresh bridge heartbeat. | +| GET | `/items/{itemId}/icon` | Same-origin operator-installed icon for a known catalogue item. Game artwork is not distributed with Palhelm. | +| POST | `/players/{uid}/item-grants` | **Admin only.** `{itemId, quantity, reason, idempotencyKey}` → `202` durable queued grant. Online players and allowlisted items only. Never accepts a display-name target or arbitrary command. | +| GET | `/item-grants` | **Admin only.** Newest 100 durable audit records, reconciling any bridge result files. | +| GET | `/item-grants/{requestId}` | **Admin only.** One audited grant and its current `queued`, `succeeded`, or `failed` state. A missing/late result is never retried automatically. | | GET/PUT | `/whitelist` | Legacy path for the local player-annotation ledger: `[{steamId, name?}]`; PUT replaces. It does **not** enforce who may join. | ## Guilds diff --git a/docs/SERVER-MOD-INTEGRATIONS-PLAN.md b/docs/SERVER-MOD-INTEGRATIONS-PLAN.md new file mode 100644 index 0000000..9c9e164 --- /dev/null +++ b/docs/SERVER-MOD-INTEGRATIONS-PLAN.md @@ -0,0 +1,542 @@ +# Palhelm Server-Mod Integrations Plan + +Status: item-grant control plane implemented; native-Linux loader/transport validated, inventory mutation still gated +Last reviewed: 2026-07-18 + +This document plans optional server-side mutation features for Palhelm. Nothing in +this plan authorizes installing a mod, changing the game-server runtime, editing a +live save, or restarting the Palworld server. Every provider described here is +disabled by default and requires an isolated compatibility test, a current backup, +an empty maintenance window, and explicit operator approval before a production +game-server restart. + +### Implementation checkpoint (2026-07-18) + +The repository now contains the item-grant portion of this design: + +- disabled-by-default config and a fail-closed capability endpoint; +- a strict, versioned operator catalogue plus complete-icon importer; +- an independently generated bridge allowlist with per-item quantity ceilings; +- same-origin item icon serving and an admin player-detail grant dialog; +- SQLite migration 013 with actor, target, reason, idempotency, status, and result; +- a one-at-a-time atomic local spool that never retries an uncertain mutation; and +- `mods/palhelm-item-bridge`, a vanilla-client UE4SS server bridge source package. + +The bridge is deliberately shipped with `ProductionValidated=false`. BES Pals runs +the native Linux `PalServer-Linux-Shipping` binary, which cannot load the officially +supported Windows server mod format. On 2026-07-18, after a verified zero-player +backup, the server successfully loaded the checksum-pinned experimental native-Linux +UE4SS build and the path-neutral Palhelm Lua bridge on Palworld `v1.0.1.100619`. +REST, game-data API, world identity, and the bridge heartbeat remained healthy. A +synthetic spool request was rejected with `validation_required`, proving the atomic +request/result transport without reaching player lookup or inventory mutation. + +This does **not** validate item grants. The remaining gate requires a consenting +online test player on the exact build, one bounded low-value grant, inventory and +UID verification, save/reconnect persistence, duplicate-request rejection, and a +post-test backup. Until those checks pass, the live capability must remain +`validation_required` and Palhelm must not expose the grant action as ready. + +The optional PalMods one-time converter was validated against the 2026-07-18 +rendered reference (2,466 records; 2,455 rows with game art). It is not a Palhelm +runtime dependency. Eleven source rows had no game icon and remain non-grantable; +the UI never substitutes another item's art. Operator-imported assets stay in the +data volume and must not be committed or included in a Palhelm release image. + +## Constraints + +- Keep Palhelm's `/api/integration/v1` API permanently read-only. +- Do not let the AI assistant, viewer sessions, or ordinary integration keys invoke + mutations. +- Use stable player and Pal instance IDs. Display names are presentation only. +- Never edit a live Palworld save to implement an online action. +- Never expose arbitrary RCON, Lua, shell, item IDs, or Pal templates through a + typed Palhelm action. +- Keep clients vanilla. Server features may only use Palworld species, items, + models, and replicated state already present in the game. +- A missing, stale, or incompatible mod must disable the action clearly rather + than falling back to an unsafe mechanism. + +Pocketpair's current server-mod instructions require a game-server restart and say +that officially supported server-side mod loading is limited to the Windows +dedicated-server edition. A package must explicitly contain a server install rule. +Community Linux/Proton/UE4SS claims therefore need separate verification and do not +inherit official support: + +- +- + +## Shared architecture + +```text +Panel typed action ---------+ + +--> Palhelm authenticated admin API +Discord admin slash command + | + +--> validation + idempotency + +--> durable audit record + +--> optional mutation provider + | + structured RCON or local spool + | + server-side Palworld mod + | + authoritative game state +``` + +Palhelm should expose a provider interface rather than depending directly on one +third-party mod: + +```go +type MutationProvider interface { + Capabilities(ctx context.Context) Capabilities + GrantItem(ctx context.Context, request GrantItemRequest) GrantItemResult + WonderTrade(ctx context.Context, request WonderTradeRequest) WonderTradeResult +} +``` + +The default provider reports `disabled`. Diagnostics should distinguish disabled, +ready, incompatible, unreachable, degraded, and version-mismatched states. + +Preferred transport is a short, structured RCON command. If safe custom RCON +registration is not practical, the fallback is an explicitly mounted, local-only +request/result spool using atomic file renames. Do not add a network listener to +the game process merely for these features. + +Every mutation carries a unique request ID. The provider must retain enough recent +completed IDs to return the original result instead of performing the action twice. +Palhelm must never blindly retry a timed-out mutation whose outcome is unknown. + +## Item grants + +### Goal + +Allow an administrator to grant a bounded quantity of a known-safe item to one +online player from the Palhelm player view or an admin-only Discord command. + +The current Admin Commands mod implements in-game chat commands such as +`!give playername item:amount`, but does not document an RCON or server-console +contract. It targets a name rather than a stable UID and is All Rights Reserved: + +- + +### Preferred provider order + +1. Ask the Admin Commands author for a stable UID-based, machine-readable RCON + contract and permission boundaries. +2. If that is unavailable, implement a minimal Palhelm-owned server bridge. +3. Do not patch and redistribute the third-party mod without permission. +4. Do not impersonate chat, automate a player client, or edit the save. + +Suggested bridge command: + +```text +PalhelmGive +``` + +Suggested result envelope: + +```json +{"v":1,"requestId":"...","status":"delivered","accepted":10} +``` + +### Palhelm contract + +```http +GET /api/v1/items?query= +POST /api/v1/players/{uid}/item-grants +GET /api/v1/item-grants/{requestId} +GET /api/v1/item-grants +``` + +The request contains an allowlisted item ID, bounded quantity, reason, +idempotency key, source, and external actor metadata. The durable record contains +the authenticated panel principal as well as the claimed Discord actor when the +trusted bot is the caller. + +Initial delivery is online-only. Later, Palhelm may maintain a cancellable pending +delivery queue keyed by player UID. The bridge must validate inventory capacity and +return the accepted quantity. It must not drop overflow into the world. Palhelm +should rely on the normal world-save interval instead of forcing a save after every +grant. + +### Safety policy + +- Versioned item catalog with display names and internal asset IDs. +- Reject debug, placeholder, quest, key, paid-content, story, and unsafe internal + items. +- Per-item quantity caps, per-request caps, cooldowns, and a required reason. +- Admin confirmation and an audit entry for success, failure, and uncertain + outcomes. +- No free-form command or asset input in the panel or Discord. + +### User interfaces + +- Panel: **Give item...** on the existing player detail/action surface, with item + autocomplete, quantity, reason, confirmation, capability state, and recent + results. +- Discord: `/grant-item player: item: quantity: reason:`, + centrally gated by the configured admin role and answered ephemerally. + +## Wonder Trade + +### Feasibility verdict + +Feasible in principle as a server-only mod with a Discord slash-command interface, +provided all traded Pals use vanilla game assets and the recipient is online. +Players should not need to install anything: Discord supplies the selection and +confirmation UI, while the server mod performs and replicates the authoritative +Pal-container mutation. + +This is **large and high-risk compared with item grants**. The unknown that must be +proven in an isolated server is not random selection or persistence; it is safely +moving one exact, fully populated Pal instance out of a live personal party/Palbox +and replacing it atomically without duplication, loss, stale-container writes, or +save corruption. + +Technical precedent exists but is not proof of 1.0 production compatibility: + +- UE4SS can hook loaded Unreal `UFunction` calls: + +- Current server-oriented Lua mods manipulate authoritative party/summon state: + +- PalDefender historically exposed RCON `givepal` and Pal-template operations, + showing that server-authoritative Pal creation and RCON control are possible. + Its currently indexed release is not a Palworld 1.0 dependency candidate: + + +No current, maintained Palworld 1.0 Wonder Trade implementation was found in the +reviewed CurseForge, Nexus, Workshop, or general web results. This should be treated +as a new mod, not an integration expected to exist already. + +### Player experience + +The first release should use three Discord subcommands: + +```text +/wondertrade trade pal: +/wondertrade pool +/wondertrade history +``` + +`trade` requires the existing Discord-to-Palworld profile link. Autocomplete shows +only that player's current party and box Pals, with a maximum of 25 Discord choices +per response and search to narrow the list. The visible choice contains species, +nickname when safe, level, sex, Alpha/Lucky markers, and placement; its value is an +opaque short-lived bot token, not a raw Pal instance ID. + +The bot replies ephemerally with a confirmation card: + +```text +Wonder Trade + +Offer: Lv. 31 Anubis · Male · Box 4, slot 12 +You will receive one random eligible Pal from the server pool. +This exchange is permanent and has a 12-hour cooldown. + +[Confirm trade] [Cancel] +``` + +After confirmation: + +```text +Wonder Trade complete! + +You sent: Lv. 31 Anubis +You received: Lv. 28 Petallia ✨ Lucky +Next trade: in 12 hours +Pool: 100 Pals +``` + +Public announcements should be configurable and should normally announce only +Lucky, Alpha, or high-rarity results. Ordinary trades remain private to avoid +channel spam. + +### Eligibility rules + +Initial implementation is online-only. The selected Pal must be revalidated by the +mod at execution time: + +- The player UID is connected and matches the linked Discord profile. +- The Pal instance still belongs to that player. +- It is still in the party or personal Palbox position selected. +- It is not deployed as a base worker, breeding, viewing-cage, expedition, or other + facility Pal. +- It is not currently summoned, mounted, incapacitated in a transient state, or + otherwise held by a live gameplay action. +- Its species and saved fields can be represented by the bridge's current schema. +- It is not a human/NPC, tower/raid-only actor, invalid boss form, placeholder, or + blocked story/event specimen. + +For the first prototype, box-only trades are safer. Party support should be enabled +only after the same atomic swap and disconnect-race tests pass. A party Pal may need +to be recalled before trading, and the active party slot should never be mutated +while summoned. + +### Authoritative exchange algorithm + +The mod owns the live transaction. Palhelm never constructs or edits a Pal save +record. + +1. Accept a request ID, player UID, and exact offered Pal instance ID. +2. Reject a duplicate request by returning its stored result. +3. Revalidate ownership, placement, eligibility, cooldown, and schema version. +4. Select and reserve an eligible pool entry **before** adding the offered Pal to + the pool, so a player cannot immediately draw the Pal they just submitted. +5. Persist a write-ahead transaction journal containing encrypted or access- + restricted full snapshots of both specimens and their intended locations. +6. Replace the offered Pal in its exact container slot with the received specimen, + updating the received specimen's ownership/container references through game + APIs on the game thread. +7. Add the offered specimen to the virtual pool only after the replacement succeeds. +8. Persist pool state, cooldown, request result, and audit metadata atomically. +9. Clear the journal. On startup, reconcile an incomplete journal without randomly + repeating the trade. + +If the game does not expose a safe slot-replacement operation, the prototype must +stop. A remove-then-create sequence without rollback and idempotency is not +acceptable on the production world. + +### Pool persistence + +The authoritative pool belongs to the mod, not the Discord bot and not the parsed +world-save projection. Each entry must preserve every transferable per-instance +field needed to recreate the same legitimate Pal, including at least: + +- species/character ID and legal variant flags; +- level and experience; +- gender, rank/stars, IV/talent values, and soul upgrades when supported; +- passive and active skills; +- Alpha/Lucky state; +- nickname according to the configured privacy policy; +- schema/game/mod versions and anonymous provenance needed for fairness checks. + +Use an atomic, checksummed state file plus rolling last-good copies for an initial +pool of roughly 100 entries. A small SQLite database is preferable if the chosen +native mod runtime can ship it safely; plain Lua should use bounded JSON with temp +file, fsync where available, and rename semantics. Store it on a dedicated durable +volume outside replaceable mod binaries. Palhelm backups should gain an explicit +optional artifact for the pool state rather than assuming the world archive already +contains it. + +The pool count remains stable because every successful transaction removes one and +adds one. Admin reseeding, pruning, import, and reset require separate dry-run and +confirmation operations and must never happen automatically after a parse error or +empty read. + +An optional persisted maintenance timer may keep the pool fresh without changing +its size. At each interval it can retire a configured number of ordinary pool +entries and replace them with newly generated, legal server-seed specimens from the +same quality bands. Rotation defaults off, records the exact next deadline, and +must not run a burst of missed rotations after a restart. If the pool is below its +target because recovery quarantined entries, a separate `replenish-below-target` +policy may fill only the deficit. Neither timer may consume player-owned Pals. + +### Initial seed and pool quality + +The first seed should contain approximately 100 legal, vanilla Pals generated once +from version-pinned templates. Proposed distribution: + +| Band | Count | Intended contents | +|---|---:|---| +| Everyday | 55 | Useful common/uncommon species, mostly levels 15-30 | +| Useful/rare | 30 | Better workers, mounts, or less common species, mostly levels 20-38 | +| Special | 12 | Rare species or notably strong legal specimens, mostly levels 25-42 | +| Jackpot | 3 | A small mix of Lucky/Alpha or rare specimens; no endgame-exclusive giveaway | + +Exact species and stats must come from the installed 1.0 game-data revision, remain +inside vanilla legal ranges, and pass a PalDefender-style legality audit. Do not use +raw `BOSS_` identifiers merely to create an Alpha appearance; variant construction +must match the game's current legitimate representation. + +Unrestricted one-for-one exchange will eventually turn a good seed into a pool of +low-value common Pals. Recommended policy is a soft quality band: + +- score the offered specimen from species availability, level band, legal IVs, + passives, rank, Alpha/Lucky state, and configured exclusions; +- normally draw from the same band; +- allow a small configurable chance to draw one neighboring band; +- never permit a low-band offer to consume the small jackpot reserve directly; +- periodically report pool health to operators, but do not silently manufacture + replacements on a timer. + +For a more chaotic server, operators may configure uniform random selection and a +scheduled refill target, accepting the economy inflation explicitly. All selection +uses a cryptographically strong or OS-provided random source when available; never +derive results from predictable request IDs or timestamps. + +### Cooldowns and abuse controls + +- Default cooldown proposal: 12 hours per stable Palworld player UID. +- Configurable minimum/maximum level, placement policy, excluded species/forms, + pool size target, quality policy, and rare-result announcement channel. +- Optional daily and weekly trade caps in addition to the cooldown. +- Draw before deposit and avoid the same anonymous donor when enough eligible pool + entries exist. +- Rate-limit failed requests so repeatedly selecting a moving/summoned Pal cannot + stress the server. +- Do not reveal the full pool or odds of individual specimens to ordinary users. +- Admin reset/bypass actions require a reason and permanent audit entry. + +Cooldown and completed request IDs must persist with the authoritative pool state. +Palhelm may mirror them for UI, history, and diagnostics, but the mod must enforce +them so another bridge caller cannot bypass policy. + +### Bridge contract + +Suggested command: + +```text +PalhelmWonderTrade +``` + +Suggested bounded response: + +```json +{ + "v": 1, + "requestId": "...", + "status": "delivered", + "sent": {"speciesId": "Anubis", "level": 31}, + "received": {"speciesId": "FlowerDinosaur", "level": 28, "lucky": true}, + "poolSize": 100, + "cooldownUntil": "2026-07-17T03:00:00Z" +} +``` + +Expected failure codes include `provider_unavailable`, `player_offline`, +`pal_not_found`, `not_owner`, `placement_changed`, `pal_busy`, `pal_ineligible`, +`cooldown`, `pool_unavailable`, `pool_schema_mismatch`, `inventory_changed`, +`delivery_uncertain`, and `rollback_failed`. + +### Palhelm APIs and storage + +Admin/session mutation routes: + +```http +GET /api/v1/wondertrade/capabilities +GET /api/v1/wondertrade/pool-summary +POST /api/v1/wondertrade/trades +GET /api/v1/wondertrade/trades/{requestId} +GET /api/v1/wondertrade/history +``` + +The public Integration API may eventually expose only an aggregate pool count, +whether trading is available, and cooldown-policy text. It must never expose the +pool roster, full specimen stats, donor identity, internal paths, transaction +journals, or mutation endpoints. + +Palhelm stores a durable audit mirror containing request ID, linked Discord actor, +player UID alias, offered/received public summary, timestamps, outcome, provider +version, and failure category. Full Pal records remain in the mod's restricted +state, not in general panel events or Discord logs. + +### Configuration proposal + +```yaml +wonderTrade: + enabled: false + cooldown: 12h + onlineOnly: true + allowedPlacements: [box] + poolTarget: 100 + selectionPolicy: soft-bands + rotation: + enabled: false + every: 24h + count: 2 + replenishBelowTarget: true + announceRareResults: true + announceAlpha: true + announceLucky: true + nicknamePolicy: reset-to-species + minLevel: 10 + maxLevel: 60 +``` + +Configuration edits do not regenerate or normalize an existing pool. Any operation +that changes stored specimens must be a separately previewed admin action. + +## Delivery phases + +### Phase 0 — zero-impact feasibility + +- Confirm whether the production game container is native Linux or a Windows + server under Proton/Wine, without changing it. +- Static-review current 1.0-compatible mod loaders, signatures, licenses, and + server-only behavior. +- Build a disposable isolated server using separate ports and a copied or synthetic + test world; never point a prototype at the live save volume. +- Prove read-only discovery of exact player UID, party/box containers, Pal instance + IDs, and live placement. +- Prove creation of one legal vanilla test Pal on the disposable server. +- Prove an atomic same-slot swap, save, restart, and rollback before implementing a + random pool. + +### Phase 1 — provider contracts and simulators + +- Add disabled-by-default provider interfaces and capability diagnostics. +- Implement item-grant and Wonder Trade request/result schemas, idempotency, audit + migrations, fake providers, OpenAPI, and contract tests. +- Build a deterministic pool simulator and property tests for constant size, + cooldowns, band invariants, duplicate requests, and crash recovery. + +### Phase 2 — isolated server bridge + +- Implement the smallest possible server-only bridge. +- Add restricted persistent state, checksums, last-good copies, and transaction + journal recovery. +- Exercise disconnect races, full containers, moved Pals, summoned party Pals, + invalid forms, corrupted state, timeouts, and duplicate delivery. +- Verify an unmodified client can join, trade through Discord, see the received Pal, + save, leave, and rejoin without installing anything. + +### Phase 3 — Palhelm and Discord UX + +- Add panel capability/pool/audit views and guarded admin actions. +- Add `/grant-item` for administrators. +- Add `/wondertrade trade|pool|history`, profile-link enforcement, ephemeral + confirmation, short-lived selection tokens, and rare-result announcements. +- Keep raw IDs, donor identity, paths, and full provider responses out of Discord. + +### Phase 4 — production decision + +- Review compatibility against the exact live Palworld revision. +- Take and verify a full world backup plus a separate pool-state backup. +- Require zero online players and explicit approval immediately before the first + game-server restart. +- Install one component at a time, observe startup and save behavior, run a bounded + admin-only smoke trade, and retain a tested `-NoMods`/known-good rollback path. + +## Required test matrix + +- Viewer, non-admin Discord member, admin, and forged actor authorization. +- Linked/unlinked/mislinked Discord profile. +- Online/offline player and disconnect during each transaction stage. +- Party, box, moved, summoned, base-assigned, breeding, invalid, human, Alpha, + Lucky, boss, and future unknown Pal forms. +- Full/changed destination containers and stale Palhelm snapshots. +- Duplicate request before, during, and after completion. +- Provider timeout before mutation, after mutation, and during response writing. +- Process crash after journal, after removal, after replacement, and before pool + persistence. +- Pool checksum failure, old schema, partial file, missing state, and recovery from + last-good copy. +- Save, scheduled save overlap, graceful server shutdown, restart, mod disable, and + mod upgrade. +- 100-entry pool selection distribution, cooldown persistence, band conservation, + and absence of immediate self-draw. +- Bot and panel restart while the game server remains running. + +## Stop conditions + +Do not proceed to production if any of the following remains true: + +- Only offline save editing can perform the exchange. +- The server cannot identify and mutate an exact Pal instance atomically. +- A failed add can permanently consume the offered Pal. +- Request replay can duplicate either specimen. +- The mod requires client installation for vanilla Pal replication. +- The supported mod loader requires replacing the live runtime without an isolated + compatibility and rollback proof. +- Pool state cannot be backed up and recovered independently. +- A normal Palworld update silently loads an incompatible bridge. diff --git a/docs/item-catalog-manifest.example.json b/docs/item-catalog-manifest.example.json new file mode 100644 index 0000000..32485d0 --- /dev/null +++ b/docs/item-catalog-manifest.example.json @@ -0,0 +1,26 @@ +{ + "gameVersion": "1.0.1", + "source": { + "name": "Operator export from the matching Palworld build", + "generatedAt": "2026-07-18T00:00:00Z" + }, + "items": [ + { + "id": "Wood", + "name": "Wood", + "description": "Material gathered from trees.", + "category": "Material", + "rarity": "Common", + "iconPath": "/operator/export/icons/Wood.webp", + "maxQuantity": 9999, + "grantable": true + }, + { + "id": "Example_Internal_Item", + "name": "Example internal item", + "category": "Internal", + "maxQuantity": 1, + "grantable": false + } + ] +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0a2f7ba..5c664c5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -18,6 +18,9 @@ import type { GuildDetail, IntegrationKey, IntegrationKeyCreated, + ItemCatalogPage, + ItemGrant, + ItemGrantCapability, MapDataset, MetricsCurrent, MetricsHistory, @@ -134,6 +137,24 @@ export const api = { // caller's onError fallback to the placeholder mark). Proxied same-origin for CSP. avatarUrl: (uid: string): string => `${BASE}/players/${encodeURIComponent(uid)}/avatar`, }, + items: { + capability: (): Promise => + USE_MOCK ? mock.itemGrantCapability() : request("GET", "/items/capability"), + search: (q = "", limit = 50): Promise => { + if (USE_MOCK) return mock.searchItems(q, limit); + const query = new URLSearchParams({ q, limit: String(limit) }); + return request("GET", `/items?${query}`); + }, + iconUrl: (itemId: string): string => `${BASE}/items/${encodeURIComponent(itemId)}/icon`, + grant: (uid: string, itemId: string, quantity: number, reason: string, idempotencyKey: string): Promise => + USE_MOCK + ? mock.grantItem(uid, itemId, quantity, reason, idempotencyKey) + : request("POST", `/players/${encodeURIComponent(uid)}/item-grants`, { itemId, quantity, reason, idempotencyKey }), + grantStatus: (requestId: string): Promise => + USE_MOCK ? mock.itemGrantStatus(requestId) : request("GET", `/item-grants/${encodeURIComponent(requestId)}`), + grants: (): Promise => + USE_MOCK ? mock.listItemGrants() : request<{ grants: ItemGrant[] }>("GET", "/item-grants").then((result) => result.grants), + }, pals: { list: (params: PalExplorerParams = {}): Promise => { if (USE_MOCK) return mock.listPals(params); diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index 5ade7cb..ebfdf3b 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -24,6 +24,9 @@ import type { GuildDetail, IntegrationKey, IntegrationKeyCreated, + ItemCatalogPage, + ItemGrant, + ItemGrantCapability, LiveWorldActor, LiveWorldSnapshot, MapDataset, @@ -1328,6 +1331,57 @@ export async function applyConfig(): Promise { ); } +// ---------- item grants ---------- + +const mockItems = [ + { id: "Wood", name: "Wood", description: "Material gathered from trees.", category: "Material", rarity: "Common", icon: "wood.webp", maxQuantity: 9999, grantable: true }, + { id: "PalSphere", name: "Pal Sphere", description: "A basic sphere used to capture Pals.", category: "Sphere", rarity: "Common", icon: "pal-sphere.webp", maxQuantity: 999, grantable: true }, + { id: "MedicalSupplies_01", name: "Low Grade Medical Supplies", description: "Medicine for minor ailments.", category: "Medicine", rarity: "Common", icon: "medicine.webp", maxQuantity: 99, grantable: true }, +]; +const mockGrants = new Map(); + +export async function itemGrantCapability(): Promise { + requireSession(); + await latency(); + return { enabled: true, ready: true, reason: "", protocolVersion: 1, gameVersion: "1.0.1", catalogVersion: "1.0.1", catalogItems: 2466, lastHeartbeatAt: new Date().toISOString() }; +} + +export async function searchItems(q: string, limit: number): Promise { + requireSession(); + await latency(); + const needle = q.trim().toLowerCase(); + const items = mockItems.filter((item) => !needle || `${item.name} ${item.id} ${item.category}`.toLowerCase().includes(needle)); + return { items: items.slice(0, limit), total: items.length, gameVersion: "1.0.1", source: { name: "Mock operator catalogue" } }; +} + +export async function grantItem(uid: string, itemId: string, quantity: number, reason: string, idempotencyKey: string): Promise { + requireAdmin(); + await latency(150, 300); + const existing = mockGrants.get(idempotencyKey); + if (existing) return existing; + const item = mockItems.find((candidate) => candidate.id === itemId); + const player = players.find((candidate) => candidate.uid === uid); + if (!item || !player) throw new ApiRequestError(400, "invalid_grant", "The mock grant is invalid."); + const now = new Date().toISOString(); + const grant: ItemGrant = { requestId: crypto.randomUUID().replaceAll("-", ""), createdAt: now, updatedAt: now, actor: "admin", playerUid: uid, playerName: player.name, itemId, itemName: item.name, quantity, grantedQuantity: null, reason, status: "queued" }; + mockGrants.set(idempotencyKey, grant); + return grant; +} + +export async function itemGrantStatus(requestId: string): Promise { + requireAdmin(); + await latency(); + const grant = [...mockGrants.values()].find((candidate) => candidate.requestId === requestId); + if (!grant) throw new ApiRequestError(404, "grant_not_found", "Item grant not found."); + return grant; +} + +export async function listItemGrants(): Promise { + requireAdmin(); + await latency(); + return [...mockGrants.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + // ---------- events ---------- export async function listEvents(limit: number, kind?: string): Promise { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b8664dd..866876a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -171,6 +171,53 @@ export interface PlayerDetail extends Player { activity: PlayerActivity; } +// ---------- Item grants (admin-only mutation boundary) ---------- +export interface ItemGrantCapability { + enabled: boolean; + ready: boolean; + reason: string; + protocolVersion: number; + gameVersion?: string; + catalogVersion?: string; + catalogItems: number; + lastHeartbeatAt?: string; +} + +export interface GrantableItem { + id: string; + name: string; + description?: string; + category?: string; + rarity?: string; + icon?: string; + maxQuantity: number; + grantable: boolean; +} + +export interface ItemCatalogPage { + items: GrantableItem[]; + total: number; + gameVersion: string; + source: { name: string; url?: string; generatedAt?: string } | null; +} + +export interface ItemGrant { + requestId: string; + createdAt: string; + updatedAt: string; + actor: string; + playerUid: string; + playerName: string; + itemId: string; + itemName: string; + quantity: number; + grantedQuantity?: number | null; + reason: string; + status: "queued" | "succeeded" | "failed"; + errorCode?: string; + errorMessage?: string; +} + export type ServerActivityWindow = "24h" | "7d" | "30d"; export interface ActivityConcurrencyBucket { diff --git a/frontend/src/routes/players/Players.css b/frontend/src/routes/players/Players.css index 3ae1e89..f1ca419 100644 --- a/frontend/src/routes/players/Players.css +++ b/frontend/src/routes/players/Players.css @@ -110,3 +110,58 @@ dialog.dialog.pal-box-dialog { width: min(780px, 94vw); } } .pal-box-tab:hover { color: var(--ink-2); background: var(--surface-2); } .pal-box-tab.is-active { color: var(--accent-ink); border-color: color-mix(in srgb, var(--accent) 55%, transparent); background: var(--accent-soft); } + +/* ---------- audited item grants ---------- */ +dialog.dialog.item-grant-dialog { width: min(900px, 94vw); max-height: 92vh; } +dialog.dialog.item-grant-dialog .dialog-body { overflow-y: auto; overscroll-behavior: contain; } +.item-grant-form { display: flex; flex-direction: column; gap: var(--space-3); } +.item-grant-note { margin: 0; color: var(--ink-3); font-size: var(--text-xs); } +.item-grant-results { + min-height: 180px; max-height: 330px; overflow: auto; + border: 1px solid var(--line); border-radius: var(--radius-ctl); +} +.item-grant-item { + display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; gap: 10px; align-items: center; + width: 100%; padding: 8px 10px; border: 0; border-bottom: 1px solid var(--line); + color: var(--ink-1); background: var(--surface); text-align: left; cursor: pointer; +} +.item-grant-item:last-child { border-bottom: 0; } +.item-grant-item:hover { background: var(--surface-2); } +.item-grant-item.is-selected { background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); } +.item-grant-item img { width: 42px; height: 42px; object-fit: contain; border-radius: 6px; background: var(--surface-3); } +.item-grant-item .item-grant-summary { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.item-grant-meta { display: flex; align-items: center; gap: 6px; min-width: 0; } +.item-grant-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); } +.item-grant-item small { color: var(--ink-3); font-size: 10px; } +.item-grant-item code { color: var(--ink-3); font-size: 10px; } +.item-rarity { + display: inline-flex; align-items: center; width: fit-content; padding: 1px 7px; + border: 1px solid currentColor; border-radius: 999px; font-size: 10px; font-weight: 700; + line-height: 1.45; letter-spacing: .02em; background: color-mix(in srgb, currentColor 10%, transparent); +} +.item-rarity-common { color: var(--ink-3); } +.item-rarity-uncommon { color: var(--ok-ink); } +.item-rarity-rare { color: var(--accent-ink); } +.item-rarity-epic { color: var(--warn-ink); } +.item-rarity-legendary { color: var(--warn-ink); } +.item-rarity-other { color: var(--accent); } +.item-grant-fields { display: grid; grid-template-columns: minmax(110px, .35fr) 1fr; gap: var(--space-3); } +.item-grant-review { display: grid; grid-template-columns: 72px 1fr; gap: var(--space-3); align-items: center; } +.item-grant-review > img { width: 72px; height: 72px; object-fit: contain; border-radius: var(--radius-ctl); background: var(--surface-3); } +.item-grant-review > div { display: flex; flex-direction: column; gap: 5px; } +.item-grant-review-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.item-grant-review span { color: var(--ink-2); font-size: var(--text-sm); } +.item-grant-review .item-rarity { font-size: 10px; } +.item-grant-review code { color: var(--ink-3); font-size: 10px; } +.item-grant-review .banner { grid-column: 1 / -1; } +.item-grant-history { border-top: 1px solid var(--line); } +.item-grant-history > div { display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--line); } +.item-grant-history > div:last-child { border-bottom: 0; } +.item-grant-history span { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 2px; } +.item-grant-history strong { font-size: var(--text-xs); } +.item-grant-history small { overflow: hidden; color: var(--ink-3); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +@media (max-width: 560px) { + .item-grant-item { grid-template-columns: 38px minmax(0, 1fr); } + .item-grant-item code { display: none; } + .item-grant-fields { grid-template-columns: 1fr; } +} diff --git a/frontend/src/routes/players/Players.tsx b/frontend/src/routes/players/Players.tsx index 08c0bd4..35a71e7 100644 --- a/frontend/src/routes/players/Players.tsx +++ b/frontend/src/routes/players/Players.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../api/client"; -import type { Player, PlayerActivity, PlayerActivityWindow, WhitelistEntry } from "../../api/types"; +import type { GrantableItem, Player, PlayerActivity, PlayerActivityWindow, WhitelistEntry } from "../../api/types"; import { useIsAdmin } from "../../app/AuthProvider"; import { usePaletteBridge } from "../../app/paletteBridge"; import { formatDuration, formatRelativeToNow, truncateMiddle } from "../../app/format"; @@ -33,6 +33,22 @@ function initials(name: string): string { return name.slice(0, 2).toUpperCase(); } +function newIdempotencyKey(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return `panel-${Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("")}`; +} + +const knownItemRarities = new Set(["common", "uncommon", "rare", "epic", "legendary"]); + +function ItemRarityBadge({ rarity }: { rarity?: string }) { + const label = rarity?.trim(); + if (!label) return null; + const normalized = label.toLowerCase(); + const tone = knownItemRarities.has(normalized) ? normalized : "other"; + return {label}; +} + function lastSeenLabel(p: Player): string { if (p.online) return "now"; const d = new Date(p.lastSeenAt); @@ -289,6 +305,7 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k const navigate = useNavigate(); const [showAllPals, setShowAllPals] = useState(false); const [messageOpen, setMessageOpen] = useState(false); + const [itemGrantOpen, setItemGrantOpen] = useState(false); const [expandedPalId, setExpandedPalId] = useState(null); useEffect(() => setExpandedPalId(null), [uid]); @@ -299,6 +316,12 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k enabled: uid !== null, refetchInterval: 60_000, }); + const grantsQuery = useQuery({ + queryKey: ["item-grants"], + queryFn: () => api.items.grants(), + enabled: isAdmin && uid !== null, + refetchInterval: 30_000, + }); if (!uid) { return ( @@ -426,6 +449,9 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k + + {reviewing ? ( + + ) : ( + + )} + + } + > + {capability.isLoading ? ( +

Checking the server bridge…

+ ) : capability.data?.ready !== true ? ( + {capability.data?.reason ?? "The item grant bridge is unavailable."} + ) : reviewing && selected ? ( +
+ +
+
+ {quantity} × {selected.name} + +
+ {selected.category || "Item"} · {selected.id} + Recipient: {player.name} + Reason: {reason.trim()} +
+ This changes a live player inventory. Palhelm will submit it once and keep an audit record. +
+ ) : ( +
+

+ Online players only · catalogue {capability.data.catalogVersion} · {capability.data.catalogItems.toLocaleString()} validated items +

+ setSearch(event.target.value)} placeholder="Search item name, ID, or category…" autoFocus /> +
+ {catalog.isLoading && Searching catalogue…} + {catalog.data?.items.map((item) => ( + + ))} + {catalog.data && catalog.data.items.length === 0 && No grantable items match that search.} +
+ {selected && ( +
+ setQuantity(Number(event.target.value))} hint={Maximum {selected.maxQuantity.toLocaleString()}} /> + setReason(event.target.value)} placeholder="Why is this being granted?" /> +
+ )} +
+ )} + + ); +} + function PlayerActivitySummary({ activity }: { activity: PlayerActivity }) { return (
diff --git a/frontend/tests/item-grants.test.mjs b/frontend/tests/item-grants.test.mjs new file mode 100644 index 0000000..a6967d7 --- /dev/null +++ b/frontend/tests/item-grants.test.mjs @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const players = await readFile(new URL("../src/routes/players/Players.tsx", import.meta.url), "utf8"); +const styles = await readFile(new URL("../src/routes/players/Players.css", import.meta.url), "utf8"); +const client = await readFile(new URL("../src/api/client.ts", import.meta.url), "utf8"); + +test("player detail exposes a reviewed, online-only item grant flow", () => { + assert.match(players, /Give item…/); + assert.match(players, /disabled=\{!d\.online\}/); + assert.match(players, /Review grant/); + assert.match(players, /Confirm grant/); + assert.match(players, /This changes a live player inventory/); + assert.match(players, /ItemRarityBadge/); + assert.match(styles, /item-rarity-legendary/); + assert.match(players, /selected\.category/); +}); + +test("grant retries reuse one idempotency key and poll only the audit status", () => { + assert.match(players, /newIdempotencyKey\(\)/); + assert.match(players, /api\.items\.grant\(player\.uid, selected\.id, quantity, reason\.trim\(\), idempotencyKey\)/); + assert.match(players, /api\.items\.grantStatus\(grant\.requestId\)/); + assert.doesNotMatch(players, /api\.items\.grant\([^\n]+crypto\.randomUUID/); +}); + +test("item mutation remains outside the read-only Integration API", () => { + assert.match(client, /request\("POST", `\/players\/\$\{encodeURIComponent\(uid\)\}\/item-grants`/); + assert.doesNotMatch(client, /integration.*item-grants/i); +}); diff --git a/mods/palhelm-item-bridge/README.md b/mods/palhelm-item-bridge/README.md new file mode 100644 index 0000000..d9646c1 --- /dev/null +++ b/mods/palhelm-item-bridge/README.md @@ -0,0 +1,97 @@ +# Palhelm Item Bridge + +Server-only UE4SS bridge for Palhelm's audited item-grant workflow. Players use an +unmodified Palworld client. The bridge consumes one immutable request at a time +from a local shared directory and calls the server-authoritative player inventory +API on the Unreal game thread. + +## Compatibility boundary + +Pocketpair currently supports its server mod loader on the **Windows dedicated +server only**. This source package targets that runtime and also contains +path-neutral Lua support for a separately validated Windows/Proton or experimental +native-Linux UE4SS setup. Native Linux remains unsupported by Pocketpair and +upstream UE4SS; it must stay fail-closed until an isolated test of the exact loader +and Palworld build passes the validation checklist below. + +The bridge starts in `validation_required`, not `ready`. A Palworld update can +change reflected functions or enum behavior. `ProductionValidated = true` must be +set only after an isolated test on the exact game build verifies: + +1. the stable save UID resolves to exactly one online `PalPlayerState`; +2. `GetInventoryData()` is callable server-side; +3. `AddItem_ServerInternal` has the configured arity (current 1.0 SDK: five); +4. result `0` means the full quantity was accepted and inventory-full is nonzero; +5. reconnect and normal world save preserve the grant. + +## Layout + +Copy this directory to the UE4SS Mods directory for the selected runtime, copy +`config.example.lua` to `config.lua`, and point `SpoolDir` and `AllowlistPath` at a +directory mounted locally into both Palhelm and the game container/host. The +experimental native-Linux layout currently used for validation is +`Pal/Binaries/Linux/ue4ss/Mods/PalhelmItemBridge`; the Windows layout is +`Pal/Binaries/Win64/ue4ss/Mods/PalhelmItemBridge`. + +The catalogue importer produces the matching allowlist: + +```bash +cd backend +go run ./cmd/item-catalog-import \ + --manifest /operator/current-items.json \ + --catalog /data/item-catalog.json \ + --icons /data/item-icons \ + --allowlist /data/item-allowlist.txt +``` + +For a private/operator installation, the importer can also convert a locally +saved copy of PalMods' current game-ID reference and then cache its icon URLs at a +polite bounded rate. Review the source's current terms before use; Palhelm does not +redistribute the resulting data or art: + +```bash +curl -fL -A 'PalhelmItemCatalogImporter/1.0 (+https://github.com/8tp/palhelm)' \ + -o /operator/palmods-items.html \ + https://www.palmods.gg/docs/authors/game-ids/items + +cd backend +go run ./cmd/item-catalog-import \ + --palmods-html /operator/palmods-items.html \ + --game-version 1.0.1 \ + --catalog /data/item-catalog.json \ + --icons /data/item-icons \ + --allowlist /data/item-allowlist.txt \ + --request-delay 1s +``` + +The converter imports every available icon but allowlists only canonical gameplay +items in bounded categories. `Key Item`, `Internal & other`, noncanonical aliases, +and rows without game artwork stay non-grantable. At the 2026-07-18 review the +source exposed 2,466 item records and 2,455 game icons; the 11 rows without source +art therefore cannot receive an honest item-specific icon. + +Game icons and extracted game data are not shipped with Palhelm. The manifest is +operator-supplied and must use the exact running Palworld version. Every grantable +item requires an icon and an explicit maximum quantity; unsafe/internal entries +should remain `grantable: false`. + +## Spool protocol v1 + +- Palhelm atomically links a complete request to `/inbox.json`. +- The bridge atomically renames it to `processing.json` before execution. +- Results are written atomically to `results/.json`. +- A leftover `processing.json` after a crash becomes `failed/outcome_unknown` and + is never retried, because the mutation may already have happened. +- `capability.json` is refreshed every 10 seconds. Palhelm requires protocol, + game version, allowlist count, catalogue version, and a heartbeat newer than 45 + seconds before its admin UI can submit. + +The bridge has no socket listener, HTTP endpoint, chat command, arbitrary console +surface, name-based player fallback, or save-file editor. + +## References + +- Official server-mod support: +- UE4SS game-thread API: +- Current public Palworld SDK signature (research reference): + `UPalPlayerInventoryData::AddItem_ServerInternal(FName,int32,bool,float,bool)` diff --git a/mods/palhelm-item-bridge/Scripts/main.lua b/mods/palhelm-item-bridge/Scripts/main.lua new file mode 100644 index 0000000..0e0eaad --- /dev/null +++ b/mods/palhelm-item-bridge/Scripts/main.lua @@ -0,0 +1,278 @@ +-- Palhelm Item Bridge protocol v1. Server-only; vanilla clients. +local MOD = "PalhelmItemBridge" +local PROTOCOL = 1 + +local function log(message) + print(string.format("[%s] %s\n", MOD, tostring(message))) +end + +local source = debug.getinfo(1, "S").source:gsub("^@", ""):gsub("\\", "/") +local separator = package.config and package.config:sub(1, 1) or "\\" +local function joinPath(base, child) + if base:sub(-1) == "/" or base:sub(-1) == "\\" then + return base .. child + end + return base .. separator .. child +end + +local scriptDir = source:match("^(.+)/[^/]+$") +local modDir = scriptDir and scriptDir:match("^(.+)/Scripts$") or nil +if not modDir then + log("ERROR: cannot resolve mod directory") + return +end + +local okConfig, config = pcall(dofile, joinPath(modDir, "config.lua")) +if not okConfig or type(config) ~= "table" then + log("ERROR: config.lua is missing or invalid; copy config.example.lua first") + return +end + +local function safeToken(value) + return type(value) == "string" and value:match("^[A-Za-z0-9_]+$") ~= nil and #value <= 160 +end + +local function safeVersion(value) + return type(value) == "string" and value:match("^[A-Za-z0-9_.-]+$") ~= nil and #value <= 40 +end + +local function jsonEscape(value) + return tostring(value or ""):gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\r", "\\r"):gsub("\n", "\\n") +end + +local function readFile(path) + local file = io.open(path, "rb") + if not file then return nil end + local body = file:read("*a") + file:close() + if not body or body == "" or #body > 65536 then return nil end + return body +end + +local function writeAtomic(path, body) + local temporary = path .. ".tmp" + local file = io.open(temporary, "wb") + if not file then return false end + file:write(body) + file:close() + os.remove(path) + if not os.rename(temporary, path) then + os.remove(temporary) + return false + end + return true +end + +local allowlist = {} +local allowlistCount = 0 +local allowlistVersion = "" +local allowlistBody = readFile(config.AllowlistPath or "") +if allowlistBody then + allowlistVersion = allowlistBody:match("# gameVersion=([^\r\n]+)") or "" + local declaredCount = tonumber(allowlistBody:match("# catalogItems=(%d+)") or "") + for line in allowlistBody:gmatch("[^\r\n]+") do + local itemId, maximum = line:match("^([A-Za-z0-9_]+)%s+(%d+)$") + if itemId and maximum then + allowlist[itemId] = tonumber(maximum) + allowlistCount = allowlistCount + 1 + end + end + if declaredCount ~= allowlistCount then + log("ERROR: allowlist count does not match its header") + allowlist = {} + allowlistCount = 0 + end +end + +local configurationChecks = { + { "spool_dir", type(config.SpoolDir) == "string" and config.SpoolDir ~= "" }, + { "game_version", safeVersion(config.GameVersion or "") }, + { "allowlist_version", allowlistVersion == config.GameVersion }, + { "allowlist_items", allowlistCount > 0 }, + { "inventory_signature", config.InventorySignatureArgs == 4 or config.InventorySignatureArgs == 5 }, + { "execute_in_game_thread", type(ExecuteInGameThread) == "function" }, + { "find_all_of", type(FindAllOf) == "function" }, + -- UE4SS 3.x exposes constructors as functions on Windows while the + -- experimental native-Linux build exposes FName as callable userdata. + { "fname", type(FName) == "function" or type(FName) == "userdata" or type(FName) == "table" }, +} +local configured = true +local failedChecks = {} +for _, check in ipairs(configurationChecks) do + if not check[2] then + configured = false + failedChecks[#failedChecks + 1] = check[1] + end +end +if not configured then + log("ERROR: configuration checks failed: " .. table.concat(failedChecks, ", ")) +end + +local spool = config.SpoolDir or "" +local inbox = joinPath(spool, "inbox.json") +local processing = joinPath(spool, "processing.json") +local capability = joinPath(spool, "capability.json") + +local function utcNow() + return os.date("!%Y-%m-%dT%H:%M:%SZ") +end + +local function writeCapability() + local state = "invalid_configuration" + if configured then + state = config.ProductionValidated == true and "ready" or "validation_required" + end + local body = string.format( + '{"protocolVersion":%d,"state":"%s","gameVersion":"%s","catalogVersion":"%s","catalogItems":%d,"at":"%s"}\n', + PROTOCOL, state, jsonEscape(config.GameVersion), jsonEscape(allowlistVersion), allowlistCount, utcNow()) + writeAtomic(capability, body) +end + +local function parseRequest(body) + if not body then return nil, "request_unreadable" end + local request = { + protocolVersion = tonumber(body:match('"protocolVersion"%s*:%s*(%d+)')), + requestId = body:match('"requestId"%s*:%s*"([A-Za-z0-9_]+)"'), + playerUid = body:match('"playerUid"%s*:%s*"([A-Za-z0-9_]+)"'), + itemId = body:match('"itemId"%s*:%s*"([A-Za-z0-9_]+)"'), + quantity = tonumber(body:match('"quantity"%s*:%s*(%d+)')), + } + if request.protocolVersion ~= PROTOCOL or not safeToken(request.requestId) + or not request.playerUid or #request.playerUid ~= 32 or not request.playerUid:match("^%x+$") + or not safeToken(request.itemId) or not request.quantity then + return request, "request_invalid" + end + local maximum = allowlist[request.itemId] + if not maximum or request.quantity < 1 or request.quantity > maximum then + return request, "item_not_allowlisted" + end + return request, nil +end + +local function resultPath(requestId) + return joinPath(joinPath(spool, "results"), requestId .. ".json") +end + +local function writeResult(request, status, granted, code, message) + local body = string.format( + '{"protocolVersion":%d,"requestId":"%s","status":"%s","grantedQuantity":%d,"errorCode":"%s","errorMessage":"%s","completedAt":"%s"}\n', + PROTOCOL, jsonEscape(request.requestId), status, granted or 0, + jsonEscape(code), jsonEscape(message), utcNow()) + if not writeAtomic(resultPath(request.requestId), body) then + log("ERROR: could not write result for " .. request.requestId .. "; processing file retained") + return false + end + os.remove(processing) + return true +end + +local function hex32(value) + local formatted = string.format("%016x", tonumber(value) or 0) + return formatted:sub(-8) +end + +local function playerUid(playerState) + local ok, uid = pcall(function() + local value = playerState.PlayerUId + return string.lower(hex32(value.A) .. hex32(value.B) .. hex32(value.C) .. hex32(value.D)) + end) + return ok and uid or "" +end + +local function findPlayer(uid) + local states = FindAllOf("PalPlayerState") + if not states then return nil end + local match = nil + for _, state in ipairs(states) do + local valid = false + pcall(function() valid = state:IsValid() end) + if valid and playerUid(state) == uid then + if match then return nil end -- duplicate identity: fail closed + match = state + end + end + return match +end + +local busy = false +local function processInbox() + if busy or not configured then return end + if not readFile(inbox) then return end + if not os.rename(inbox, processing) then return end + local request, parseError = parseRequest(readFile(processing)) + if parseError then + local fallback = { requestId = request and request.requestId or "invalid" } + writeResult(fallback, "failed", 0, parseError, "The bridge rejected an invalid request.") + return + end + if readFile(resultPath(request.requestId)) then + os.remove(processing) + return + end + if config.ProductionValidated ~= true then + writeResult(request, "failed", 0, "validation_required", "This exact game build has not passed isolated bridge validation.") + return + end + local state = findPlayer(request.playerUid) + if not state then + writeResult(request, "failed", 0, "player_not_found", "No unique online player matched the stable UID.") + return + end + + busy = true + local scheduled, scheduleError = pcall(function() + ExecuteInGameThread(function() + local ok, operationResult = pcall(function() + local inventory = state:GetInventoryData() + if not inventory or not inventory:IsValid() then error("inventory_unavailable") end + if config.InventorySignatureArgs == 5 then + return inventory:AddItem_ServerInternal(FName(request.itemId), request.quantity, false, 0.0, true) + end + return inventory:AddItem_ServerInternal(FName(request.itemId), request.quantity, false, 0.0) + end) + if not ok then + writeResult(request, "failed", 0, "inventory_call_failed", tostring(operationResult)) + else + local resultCode = tonumber(operationResult) + if resultCode == nil then + resultCode = tonumber(tostring(operationResult):match("(%d+)$")) + end + if resultCode == 0 then + writeResult(request, "succeeded", request.quantity, "", "") + elseif resultCode == nil then + writeResult(request, "failed", 0, "outcome_unknown", "The inventory result could not be interpreted; the request will not be retried.") + else + writeResult(request, "failed", 0, "inventory_rejected", "Palworld inventory operation result " .. tostring(resultCode)) + end + end + busy = false + end) + end) + if not scheduled then + writeResult(request, "failed", 0, "outcome_unknown", "The game-thread call could not be scheduled; the request was not retried: " .. tostring(scheduleError)) + busy = false + end +end + +-- A processing file surviving process death has an unknowable mutation outcome. +-- Never execute it again. +local orphan = parseRequest(readFile(processing)) +if orphan then + writeResult(orphan, "failed", 0, "outcome_unknown", "The game stopped while this grant was processing; it was not retried.") +end + +writeCapability() +LoopAsync(1000, function() + processInbox() + return false +end) +LoopAsync(10000, function() + writeCapability() + return false +end) + +local loadedState = "invalid_configuration" +if configured then + loadedState = config.ProductionValidated == true and "ready" or "validation_required" +end +log(string.format("loaded: state=%s, allowlisted items=%d", loadedState, allowlistCount)) diff --git a/mods/palhelm-item-bridge/config.example.lua b/mods/palhelm-item-bridge/config.example.lua new file mode 100644 index 0000000..8bc64cb --- /dev/null +++ b/mods/palhelm-item-bridge/config.example.lua @@ -0,0 +1,20 @@ +return { + -- Both processes must see the same physical directory. Native Linux example: + SpoolDir = [[/palhelm-item-grants]], + AllowlistPath = [[/palhelm-item-grants/item-allowlist.txt]], + -- Windows/Wine example: + -- SpoolDir = [[C:\PalhelmData\item-grants]], + -- AllowlistPath = [[C:\PalhelmData\item-allowlist.txt]], + + -- Must exactly match the catalogue and running dedicated-server build. + GameVersion = "1.0.1", + + -- Current public 1.0 SDK exposes five parameters. Never auto-fallback after + -- a failed call: a fallback could duplicate an item whose first result was + -- merely unreadable. + InventorySignatureArgs = 5, + + -- Fail closed until an isolated copy of this exact game build passes the + -- checklist in README.md. + ProductionValidated = false, +} diff --git a/mods/palhelm-item-bridge/enabled.txt b/mods/palhelm-item-bridge/enabled.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mods/palhelm-item-bridge/enabled.txt @@ -0,0 +1 @@ + diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 1e89613..2123a58 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -36,8 +36,8 @@ const composeYaml = `palhelm: const fieldNotes = [ { eyebrow: "01", - title: "Read-only by design", - body: "Palhelm never writes to your world. It reads the save file, watches the player list, and sends only the commands you tell it to. If a game update changes the save format, Palhelm says so and keeps working instead of breaking.", + title: "Read-only saves, explicit mutations", + body: "Palhelm's save parser never writes to your world. Optional item grants use a separate, disabled-by-default server bridge with an allowlist, admin confirmation, and an audit trail. If the exact game build is not validated, the action stays unavailable.", }, { eyebrow: "02", @@ -62,11 +62,12 @@ const fieldNotes = [ { eyebrow: "06", title: "One container", - body: "The whole panel is a single Docker image with its data in one folder. No database to run, nothing extra to install. It sits beside the Palworld container you already have.", + body: "The core panel is a single Docker image with its data in one folder. No database to run. Optional item grants require a separately installed UE4SS bridge on the game server; everything else works without it.", }, ]; const ledger = [ + { quote: "Save parsing stays read-only. Item grants use a separate audited bridge.", src: "item grants · security" }, { quote: "Restarts stay in your hands. Palhelm won't pretend it can restart your server.", src: "README.md" }, { quote: "Vanilla RCON mangles spaces in Broadcast; the console says so inline.", src: "console" }, { quote: "Never expose the panel to the open internet.", src: "README · security" }, @@ -74,7 +75,7 @@ const ledger = [ ]; const tallies = [ - { n: "50", label: "documented endpoints" }, + { n: "67", label: "documented endpoints" }, { n: "32", label: "slash commands" }, { n: "400+", label: "tests" }, { n: "9", label: "screens" }, @@ -116,8 +117,8 @@ const tallies = [

Take the helm.

Palhelm is a control panel and Discord companion for your Palworld - server. You host it yourself, in one container. It reads your world and - never writes to it. + server. You host it yourself, in one container. Save parsing is read-only; + optional item grants use a separate, audited server bridge.

diff --git a/website/src/pages/panel.astro b/website/src/pages/panel.astro index fc6dc8d..62e4b4e 100644 --- a/website/src/pages/panel.astro +++ b/website/src/pages/panel.astro @@ -96,8 +96,9 @@ const DOCS_URL = "https://docs.palhelm.com/";

Online players come from the live server, everyone else from the save file. See each player's level, playtime, guild and pal party, - with their Steam avatar. Kick, ban or whitelist from the same - screen. + with their Steam avatar. Kick, ban, or—when the disabled-by-default + UE4SS bridge has passed its exact-build checks—queue an allowlisted + item grant from the same screen.

Shown with demo data; pal art appears once icons are fetched. @@ -360,7 +361,9 @@ const DOCS_URL = "https://docs.palhelm.com/";

Under the hood: one program for the panel, one web UI, shipped as a single Docker image. Your data stays in one folder on the host. No - separate database to install or run. Full details are in + separate database to install or run. The optional item-grant bridge is + installed separately on the game server and is not required for the + ordinary read-only panel. Full details are in the docs.