Skip to content

Steam Library and Drafts

mdeguzis edited this page Jul 12, 2026 · 3 revisions

Steam Library, Verified Owner, Drafts, and Replaced-By

Everything a signed-in user gets from the issue #199 bundle: Steam library sync, verified-owner attestation, cloud report drafts, replaced-appid detection, and the surrounding admin surface.

How library and wishlist get from Steam to us (privacy-relevant summary)

This section is the plain-English answer to "does Steam sign-in transfer my library?" -- referenced from the site's privacy and about pages.

  • Sign-in does not transfer library or wishlist. Steam OpenID 2.0 gives us only a Steam ID, display name, and avatar URL. See Steam-Auth-Flow.
  • Separate pull, kicked off on first use. Library syncs the first time a signed-in user opens the profile page (no cached row -> pull). Wishlist syncs the first time the "On wishlist" browse chip is activated. Subsequent refreshes are manual (Refresh button on the profile card).
  • Server-side call using our Steam Web API key. The pull is done via IPlayerService/GetOwnedGames and/or IWishlistService/GetWishlist with ?key=STEAM_API_KEY&steamid=<user's steam id>. The key is a repo secret; the browser never sees it.
  • Public profile visibility is required and is the only opt-out. Steam only returns data when the user's Steam profile visibility is Public. Friends-only or Private returns nothing; we cache nothing and the browse chips fall back to "All". Users who want the site to hold no library/wishlist data flip their Steam profile visibility away from Public and clear the cached row on the profile page.
  • Cached shape. Appid list + count + sync timestamp only. No purchase history, no session playtime, no wishlist prices. Rows live in user_steam_library and user_steam_wishlist under owner-only RLS.
  • Removal. Manual: Clear button on the profile Library / Wishlist card. GDPR: admin_erase_user_anonymize wipes both tables.

Details of what each table holds and where the appids are used follow below.

Cached Steam library

When a user signs in with Steam, we cache their owned-games list so the browse and profile pages don't need per-view Steam Web API calls.

Table: public.user_steam_library

  • user_id (uuid, PK, references auth.users)
  • steam_id (text)
  • game_count (int)
  • appids (jsonb array of numeric appids)
  • synced_at (timestamptz)
  • RLS: owner-only select/insert/update. Anon has no access.

Sync: edge function sync-steam-library at POST /functions/v1/sync-steam-library.

  • Reads the caller's JWT for steam_id.
  • Calls IPlayerService/GetOwnedGames with include_appinfo=false, include_played_free_games=true, include_free_sub=true, skip_unvetted_apps=false. The last two are the difference between our count and ProtonDB's (they were previously N-4).
  • Upserts into user_steam_library.
  • Returns { ok, game_count, appid_count, synced_at }.

When it runs:

  • Auto-syncs on first visit to profile.html when no cached row exists.
  • Manual: the profile Library card's Refresh button.

Where the library is used

  • Profile Library card: nickname, game count, last-synced timestamp, Refresh button, and a "View my games" link that opens app.html?filter=mine prefiltered.
  • Home page bar chart (signed-in only): horizontal breakdown of tier counts across the user's owned games. Cross-references appids with search-index.json column 2 (tier).
  • Browse filter panel: new "Library" pill group with "All" and "My games". Selecting "My games" intersects the visible list with libraryAppIds and is included in the save/restore payload and the active-filter badge count.
  • Wishlist filter chip (#266 Phase 1): a sibling "Wishlist" pill group with "All" and "On wishlist" filters the browse list to games in the user's synced Steam wishlist. Backed by the user_steam_wishlist table (owner-only RLS, PK on user_id) and the sync-steam-wishlist edge function, which calls IWishlistService/GetWishlist/v1/ and stores appids sorted by user priority. Loaded lazily on first chip activation via js/app/lib/user-wishlist.js.
  • Deck Verified filter chip (#266 Phase 2): pill group with Verified / Playable / Unsupported / Unknown reads the same deck-status.json map the game page uses. No new backend, lazy-loads the full map via loadDeckStatusMap() on first non-"all" chip. Non-Steam ids (GOG, Epic) always pass through since Valve does not rate them.
  • Verified owner badge: at report submit time we look up user_steam_library.appids. If it contains the report's app_id, we set owner_verified=true on the row. Report cards render a small green checkmark + "Verified owner" in the author column when true.
  • Submit form pill: same lookup happens before the form renders, so users see a "Verified owner - this report will be flagged as owner-verified" pill at the top of the form.

Verified owner column

user_configs.owner_verified (boolean, default false).

Distinct from game_owned, which was unconditionally set to true for every web submission (basically a "reporter used the web form" marker, not an ownership attestation). owner_verified is only true when the library check confirms ownership.

Backfill: a single UPDATE joining user_configs with user_steam_library flips existing rows to owner_verified=true for confirmed matches. Run this whenever a user syncs their library:

UPDATE public.user_configs uc
SET owner_verified = true
FROM public.user_steam_library usl
WHERE uc.proton_pulse_user_id = usl.user_id
  AND uc.owner_verified = false
  AND usl.appids @> to_jsonb(uc.app_id::int);

Cloud report drafts

Users can save partial reports and finish them later on any signed-in device.

Table: public.user_report_drafts (composite PK on user_id, app_id).

  • form_data (jsonb): snapshot from snapshotFormData() in js/shared/drafts.js. Captures every named input value plus the progressive-question _formState (canInstall / canStart / canPlay / verdict / faults / tinkeringMethods).
  • Owner-only RLS.

Client: js/shared/drafts.js exposes getDraft, upsertDraft, deleteDraft, snapshotFormData. Submit page (js/submit/main.js) wires them:

  • On page load, if a draft exists, show a "You have a saved draft for this game (from N minutes ago). Restore draft / Discard" prompt above the form.
  • Save Draft button next to Submit sends snapshotFormData(form) to upsertDraft.
  • On successful submit, fire-and-forget deleteDraft so the row doesn't linger.

Replaced-by detection

When Steam retires an appid and remaps to a newer one (e.g. 5488 -> 45700 for Devil May Cry 4, or the Hitman 1/2 -> World of Assassination chain), we detect it during the box-art probe and surface it across the site.

Detection: scripts/pipeline/game_images.py::_extract_replaced_by. Runs only in the delisted branch (appdetails success:false + store page redirect). Fetches https://store.steampowered.com/app/{id}/ with redirects followed. If the final URL matches /app/{newid}/ with newid != id, records replaced_by: <newid> on the game-images-cache.json entry alongside status: 'delisted'.

Search-index enrichment: enrich_search_index_with_delisted padds rows to 11 columns and writes replaced_by into column 10. Column layout is now [appId, title, tier, protondbCount, pulseCount, appType, releaseYear, delisted, adult, trend, replaced_by].

Box art inheritance: when writing game-images.json, an old appid with no own header URL falls back to the new appid's URL (or standard CDN) so the old cards keep box art.

Frontend:

  • Game page renders a yellow banner: "This app has been replaced. Steam now sells this title as <newTitle>. Old app id: <old>, new app id: <new>. New reports go to the new app automatically. Old reports on this page still apply if you're playing the original build."
  • REPLACED pill next to the game title (yellow, alongside DELISTED / DEMO).
  • Submit Report button rewires to the new appid; a muted "Old build?" link falls back to the original.
  • Browse cards get a yellow REPLACED corner tag on the thumbnail.
  • Submit page renders a yellow warning banner: "Heads up: This app was replaced. Steam now sells this title as <newTitle>. Old app id: <old>, new app id: <new>. Submitting here files a report against the original build. If you're playing the current version, submit against the new appid instead."

Admin surface

Box Art Manager (admin.html?tab=boxart):

  • Loader fetches game-images-cache.json to build a knownMissingSteam Set from entries with status: 'missing' or 'delisted'. _deriveStatus flips Steam entries in that set from the optimistic default_cdn to missing so the filter shows the same games users see broken (~87 in prod).
  • Also fetches image_load_errors and partitions rows by gog:/epic: prefix into knownMissingSteam / knownMissingNonSteam. _deriveStatus treats client-reported non-Steam 404s as missing for GOG/Epic (which otherwise always look "cached" because the pipeline records URLs unconditionally).
  • Detail page header shows Box Art: <title> - <store> - App <appid> so admins can copy the appid without scrolling.
  • Refetch/probe errors render multi-line: source, HTTP status, error, attempted URL, store redirected to (when Steam redirected the appid, e.g. 5488 -> 45700), upstream body preview.

Client-side image error reporting:

  • public.image_load_errors (app_id PK, store_type, hit_count, first_seen, last_seen). Anon read + upsert-only RLS.
  • steam-img.js fires a one-per-tab-per-appid POST after the fallback chain exhausts. Session Set gates so the same tab does not POST twice per appid.

Store API Explorer (admin.html?tab=api-explorer):

  • New Steam endpoints: steam_store_redirect, steam_current_players, steam_global_achievements, steam_news, steam_reviews, steam_community_search, steam_featured, steam_featured_categories.
  • Raw response toggle: swap between the parsed data field (default) and the full edge-function envelope (endpoint, arg, url, status, data).
  • Auto follow-up: when steam_appdetails returns success:false, an inline steam_store_redirect probe runs automatically so admins see the replaced_by target without switching endpoints.

Auto-retry watcher

.github/workflows/retry-pages-build.yml in both mdeguzis/proton-pulse-web and mdeguzis/proton-pulse-web-staging. Fires on workflow_run completed for pages-build-deployment; if conclusion==failure and run_attempt==1 it kicks gh run rerun --failed. Caps at one retry so a genuinely broken deploy is not hammered.

The staging deploy-target repo has every other workflow disabled (they lack the secrets they need and were spam-failing every scheduled run). Only pages-build-deployment and this watcher are active there.

Tests

  • tests/steamLibraryApi.test.js - REST helpers for user_steam_library.
  • tests/homeLibraryChart.test.js - computeLibraryTierCounts pure function.
  • tests/authorVerifiedOwner.test.js - renderAuthorBlock badge rendering.
  • tests/draftsApi.test.js - draft REST helpers + snapshotFormData.
  • tests/importResolution.test.js - static import validator (catches import { X } from './y.js' where y.js does not export X).
  • tests/adminBoxart.test.js - regression pins for _deriveStatus signature, game-images-cache.json fetch, and image_load_errors merging.
  • tests/test_game_images_helpers.py - four cases for _extract_replaced_by (happy redirect, same appid, homepage redirect, transport error).

Clone this wiki locally