From 14f6813568386226d016d4b4dcdbcbee07ab40cb Mon Sep 17 00:00:00 2001 From: nicodes Date: Sun, 2 Aug 2026 18:54:48 -0600 Subject: [PATCH] Take the marketplace through the API, and stop pinning versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that belong together, because both narrow what a client is allowed to ask for. Packages now come back through the registry instead of from GitHub. Fetch is gone; Download resolves and then streams from an authed endpoint on the API, with the session attached. That is what makes "installing requires an account" true rather than merely enforced in this client — the TUI install path was fully anonymous, and the CLI let marketplace ids through unauthenticated — and it gives the app the same path the arcade uses instead of a second one to build. It is a product boundary, not an access control, and is not built as though it were one. The games are open source and their release assets are public; anyone can still fetch them. What the account buys is a library that follows you and a marketplace that knows who is asking. Pinning is gone with it. `add author/slug@1.2.3` no longer resolves, Resolve no longer takes a version, and SelectRelease no longer matches one. A game is not a dependency — nothing builds against one — so the reasons a package manager pins do not apply here, and the two cases people would pin for (reproducing a bug report, escaping a bad release) are author problems with author solutions. The one thing that still narrows the choice is the ABI, which was never about versions: the registry picks the newest release this binary can run, so a game that has moved on tells you to update termcade rather than handing you a package the host would refuse. Old releases stay in the store. They are a record of what was published; nothing selects them. The old spelling gets a real answer instead of "no such file", since it is in READMEs and possibly in someone's scripts. That check runs before the account check: being told to sign in and then told the syntax changed is two trips for one mistake. internal/registry had no tests, which was the wrong place for the gap — it is the trust boundary. It now has five, including that a package arrives through the registry rather than from the asset URL resolve hands out, and that a digest mismatch never reaches the disk. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 ++++- cli.go | 30 +++--- internal/registry/client.go | 79 ++++++++------- internal/registry/client_test.go | 159 +++++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 internal/registry/client_test.go diff --git a/README.md b/README.md index 4fee09e..0ed5d47 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,19 @@ The same works from the command line: ```sh termcade signup # create an account (or: termcade login) termcade add aviorstudio/brickough # add straight from the marketplace -termcade add aviorstudio/brickough@0.0.1 # or pin a version termcade add .tcade # or from a package you have (also signed in) termcade list # what's here termcade remove author/slug # take one off (updates your library too) ``` +There is no way to install an older version, and that is deliberate. A game +is not a dependency — nothing builds against one — so the reasons package +managers pin (reproducible builds, lockfiles, a transitive bump breaking you) +do not apply. `add` gives you what the author currently ships. The one thing +that does narrow the choice is the ABI: the registry picks the newest release +your arcade can actually run, so a game that has moved on to a later ABI tells +you to update termcade instead of handing you a package the host would refuse. + The registry defaults to local dev (`http://127.0.0.1:8080`, the termcade-be stack) and will point at `https://api.termca.de` once that is deployed. `TERMCADE_REGISTRY` overrides it either way — which is how you reach a local @@ -130,10 +137,16 @@ TERMCADE_REGISTRY=http://127.0.0.1:8080 termcade add you/mygame **The registry stores no packages.** It is an index: a game's releases live on its GitHub releases, and the registry records where each one is and what it -hashed to when the registry fetched and validated it. `termcade add` asks the -registry where a version lives, downloads it from GitHub, and refuses to -install anything whose sha256 does not match what the registry recorded — so a -release asset swapped after publishing fails rather than reaching a player. +hashed to when it fetched and validated them. Publishing is open source only +for now — the registry checks that a repository is publicly visible before it +will serve anything from it. + +Packages come back through the API rather than from GitHub directly. That is +what lets an install require an account and lets the arcade and the app share +one path. `termcade add` asks the registry which release to install, streams +it from the registry, and refuses to install anything whose sha256 does not +match what the registry recorded — so a release asset swapped after publishing +fails rather than reaching a player. Installed games are WebAssembly modules that run sandboxed (no filesystem, no network) under [wazero](https://wazero.io). A broken install shows up dimmed diff --git a/cli.go b/cli.go index 71cbed9..771d5f7 100644 --- a/cli.go +++ b/cli.go @@ -21,9 +21,8 @@ const usage = `termcade — an arcade in your terminal usage: termcade play (marketplace included — press m) - termcade add add a game: author/slug from the marketplace - (pin a version with @1.2.3), or a .tcade file - or URL + termcade add add a game: author/slug from the marketplace, + or a .tcade file or URL (needs an account) termcade remove remove an added game (id is author/slug) termcade list list the games in your arcade @@ -97,6 +96,15 @@ func cmdAdd(args []string) error { } src := args[0] + // Pinning is gone. Someone with the old spelling in their fingers or in a + // script should be told that, not handed a missing-file error for what is + // obviously a marketplace id. Before the account check, because this is + // the command being wrong rather than the caller being anonymous — being + // told to sign in and then told the syntax changed is two trips. + if id, _, pinned := strings.Cut(src, "@"); pinned && gameIDRe.MatchString(id) { + return fmt.Errorf("versions cannot be pinned — `termcade add %s` installs what %s currently ships", id, id) + } + session, err := registry.LoadSession() if err != nil { return err @@ -108,11 +116,9 @@ func cmdAdd(args []string) error { return fmt.Errorf("installing a game requires an account — run `termcade login` (or `termcade signup`)") } - // Marketplace id (optionally pinned, author/slug@1.2.3) → fetch through - // the registry and sync the library. - id, version, _ := strings.Cut(src, "@") - if _, statErr := os.Stat(src); gameIDRe.MatchString(id) && statErr != nil { - return addFromRegistry(session, id, version) + // Marketplace id → fetch through the registry and sync the library. + if _, statErr := os.Stat(src); gameIDRe.MatchString(src) && statErr != nil { + return addFromRegistry(session, src) } var raw []byte @@ -133,21 +139,17 @@ func cmdAdd(args []string) error { // addFromRegistry installs a marketplace id. The session is never nil: cmdAdd // turns a signed-out install away before it gets here. -func addFromRegistry(session *registry.Session, id, version string) error { +func addFromRegistry(session *registry.Session, id string) error { author, slug, _ := strings.Cut(id, "/") client := registry.New(registry.URL(session), session.Token) - resolved, err := client.Resolve(author, slug, version) + path, err := client.Download(author, slug) if errors.Is(err, registry.ErrLoginRequired) { return fmt.Errorf("your session has expired — run `termcade login`") } if err != nil { return err } - path, err := client.Fetch(resolved, slug) - if err != nil { - return err - } defer os.Remove(path) raw, err := os.ReadFile(path) diff --git a/internal/registry/client.go b/internal/registry/client.go index 9503856..586c02f 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -55,9 +55,10 @@ type Game struct { SHA256 string `json:"sha256"` } -// Resolved is where one release actually lives and what it must hash to. The -// registry hosts no packages: it hands out a GitHub asset URL plus the digest -// it recorded when it fetched and validated that package itself. +// Resolved is which release to install and what it must hash to. The registry +// stores no packages — a game's releases live on its GitHub releases — but +// the bytes come back through the registry rather than from there, so URL is +// provenance rather than somewhere this client fetches from. type Resolved struct { ID string `json:"id"` Name string `json:"name"` @@ -158,59 +159,71 @@ func (c *Client) Games() ([]Game, error) { return games, c.do(http.MethodGet, "/v1/games", nil, &games) } -// Resolve asks the registry where a version lives. An empty version means -// the newest one. +// Resolve asks the registry which release to install: the newest one this +// arcade can run. +// +// There is no way to ask for an older version. A game is not a dependency — +// nothing builds against one — so the only version worth installing is what +// the author currently ships. // // The ABI this arcade speaks goes with the request, so the registry can pick // the newest release this binary can actually run rather than the newest one // that exists. Without it a game that has moved on to a later ABI would // resolve, download, and only then be refused by the host. -func (c *Client) Resolve(author, slug, version string) (Resolved, error) { +func (c *Client) Resolve(author, slug string) (Resolved, error) { q := url.Values{} q.Set("abi", strconv.Itoa(sdk.ABIVersion)) - if version != "" { - q.Set("version", version) - } var out Resolved return out, c.do(http.MethodGet, "/v1/games/"+author+"/"+slug+"/resolve?"+q.Encode(), nil, &out) } -// Download resolves a game and fetches its package to a temp file. The caller -// removes the returned path. +// Download resolves a game and fetches its package to a temp file, verifying +// it against the digest the registry attested. The caller removes the +// returned path. +// +// The bytes come from the registry, not from GitHub. That is what lets an +// install require an account and lets the arcade and the app take the same +// path; the games are open source and their assets are public, so it is a +// product boundary rather than one that keeps anybody out. +// +// The digest still does real work: it is the tie between what arrives and +// what the registry reviewed at publish time, across a hop the registry does +// not control. A mismatch or a missing digest is fatal. func (c *Client) Download(author, slug string) (string, error) { - resolved, err := c.Resolve(author, slug, "") + resolved, err := c.Resolve(author, slug) if err != nil { return "", err } - return c.Fetch(resolved, slug) -} - -// Fetch downloads a resolved package and verifies it against the digest the -// registry attested. -// -// The bytes come from GitHub, not from the registry, so the digest is the -// only thing tying what arrives to what the registry reviewed — a release -// asset can be deleted and re-uploaded under the same tag. Unlike the old -// download path, where the checksum was a header the sender could simply -// omit, a mismatch or a missing digest here is fatal. -// -// Nothing authenticates this request: the registry token is for the registry, -// and must not be sent to a third-party host. -func (c *Client) Fetch(resolved Resolved, slug string) (string, error) { - if !strings.HasPrefix(resolved.URL, "https://") { - return "", fmt.Errorf("registry returned a non-https package url for %s", resolved.ID) - } if len(resolved.SHA256) != 64 { return "", fmt.Errorf("registry published no checksum for %s %s", resolved.ID, resolved.Version) } - resp, err := c.http.Get(resolved.URL) + q := url.Values{} + q.Set("abi", strconv.Itoa(sdk.ABIVersion)) + req, err := http.NewRequest(http.MethodGet, + c.baseURL+"/v1/games/"+author+"/"+slug+"/download?"+q.Encode(), nil) if err != nil { - return "", fmt.Errorf("downloading %s: %w", resolved.URL, err) + return "", err + } + if c.token != "" { + req.Header.Set("Authorization", c.token) + } + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("registry unreachable: %w", err) } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return "", ErrLoginRequired + } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("downloading %s: %s", resolved.URL, resp.Status) + var msg apiMessage + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + if json.Unmarshal(raw, &msg) == nil && msg.Message != "" { + return "", fmt.Errorf("downloading %s: %s", resolved.ID, msg.Message) + } + return "", fmt.Errorf("downloading %s: HTTP %d", resolved.ID, resp.StatusCode) } tmp, err := os.CreateTemp("", slug+"-*.tcade") diff --git a/internal/registry/client_test.go b/internal/registry/client_test.go new file mode 100644 index 0000000..4a20439 --- /dev/null +++ b/internal/registry/client_test.go @@ -0,0 +1,159 @@ +package registry + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + + "github.com/aviorstudio/termcade/sdk" +) + +const pkg = "a package, as far as this test is concerned" + +func digestOf(b string) string { + sum := sha256.Sum256([]byte(b)) + return hex.EncodeToString(sum[:]) +} + +// stubRegistry answers resolve and download the way the API does. body is what +// /download returns; sha is what /resolve claims it hashes to, so a test can +// make the two disagree. +type stubRegistry struct { + sha string + body string + // downloadAuth records the Authorization header the download arrived with. + downloadAuth string + // resolveQuery records the query resolve arrived with. + resolveQuery url.Values + // assetHits counts requests to the GitHub URL resolve hands out. It must + // stay zero: packages come through the registry now. + assetHits int + status int +} + +func (s *stubRegistry) serve(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/v1/games/aviorstudio/brickough/resolve", func(w http.ResponseWriter, r *http.Request) { + s.resolveQuery = r.URL.Query() + json.NewEncoder(w).Encode(Resolved{ + ID: "aviorstudio/brickough", Name: "Brickough", Version: "1.0.0", + Asset: "brickough.tcade", SHA256: s.sha, ABI: sdk.ABIVersion, + // Deliberately pointing back at this same server, so a client + // that still fetched from here would be caught by assetHits + // rather than by a network error that looks like a flake. + URL: "https://" + r.Host + "/asset", + }) + }) + mux.HandleFunc("/v1/games/aviorstudio/brickough/download", func(w http.ResponseWriter, r *http.Request) { + s.downloadAuth = r.Header.Get("Authorization") + if s.status != 0 { + w.WriteHeader(s.status) + json.NewEncoder(w).Encode(apiMessage{Message: "nope"}) + return + } + w.Write([]byte(s.body)) + }) + mux.HandleFunc("/asset", func(w http.ResponseWriter, r *http.Request) { + s.assetHits++ + w.Write([]byte(s.body)) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// The whole point of the change: a package arrives through the registry, with +// the session attached, and the GitHub URL resolve mentions is never fetched. +func TestDownloadComesThroughTheRegistry(t *testing.T) { + stub := &stubRegistry{sha: digestOf(pkg), body: pkg} + srv := stub.serve(t) + + path, err := New(srv.URL, "session-token").Download("aviorstudio", "brickough") + if err != nil { + t.Fatalf("download: %v", err) + } + defer os.Remove(path) + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != pkg { + t.Errorf("downloaded %q, want the package", got) + } + if stub.downloadAuth != "session-token" { + t.Errorf("download sent Authorization %q; installing is supposed to require an account", stub.downloadAuth) + } + if stub.assetHits != 0 { + t.Errorf("the client fetched the GitHub asset %d times; packages come through the registry now", stub.assetHits) + } +} + +// The digest is the tie between what arrives and what the registry validated +// at publish time. A package that does not match it must not reach the disk. +func TestDownloadRefusesAMismatchedDigest(t *testing.T) { + stub := &stubRegistry{sha: digestOf("what was published"), body: "something else entirely"} + srv := stub.serve(t) + + path, err := New(srv.URL, "session-token").Download("aviorstudio", "brickough") + if err == nil { + os.Remove(path) + t.Fatal("a package that did not match its digest was installed") + } + if path != "" { + t.Errorf("a rejected download left %s behind", path) + } + if got := err.Error(); !strings.Contains(got, "checksum mismatch") { + t.Errorf("error does not say what went wrong: %q", got) + } +} + +// A registry that publishes no digest leaves nothing to verify against, which +// is worse than refusing. +func TestDownloadRefusesAMissingDigest(t *testing.T) { + stub := &stubRegistry{sha: "", body: pkg} + srv := stub.serve(t) + + if _, err := New(srv.URL, "session-token").Download("aviorstudio", "brickough"); err == nil { + t.Fatal("a package with no attested digest was installed") + } +} + +// An expired or absent session has to be legible as one, so the CLI and the +// TUI can say "sign in" rather than "HTTP 401". +func TestDownloadReportsAnUnauthorizedResponse(t *testing.T) { + stub := &stubRegistry{sha: digestOf(pkg), body: pkg, status: http.StatusUnauthorized} + srv := stub.serve(t) + + _, err := New(srv.URL, "").Download("aviorstudio", "brickough") + if !errors.Is(err, ErrLoginRequired) { + t.Fatalf("401 gave %v, want ErrLoginRequired", err) + } +} + +// Resolve sends the ABI so the registry can pick a release this binary can +// run, and sends no version at all — pinning is gone. +func TestResolveSendsTheABIAndNoVersion(t *testing.T) { + stub := &stubRegistry{sha: digestOf(pkg), body: pkg} + srv := stub.serve(t) + + if _, err := New(srv.URL, "").Resolve("aviorstudio", "brickough"); err != nil { + t.Fatalf("resolve: %v", err) + } + if got := stub.resolveQuery.Get("abi"); got != strconv.Itoa(sdk.ABIVersion) { + t.Errorf("abi = %q, want %d", got, sdk.ABIVersion) + } + if stub.resolveQuery.Has("version") { + t.Errorf("resolve still asks for a version: %v", stub.resolveQuery) + } +}