Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file-or-url>.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
Expand All @@ -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
Expand Down
30 changes: 16 additions & 14 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ const usage = `termcade — an arcade in your terminal

usage:
termcade play (marketplace included — press m)
termcade add <game> add a game: author/slug from the marketplace
(pin a version with @1.2.3), or a .tcade file
or URL
termcade add <game> add a game: author/slug from the marketplace,
or a .tcade file or URL (needs an account)
termcade remove <id> remove an added game (id is author/slug)
termcade list list the games in your arcade

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
79 changes: 46 additions & 33 deletions internal/registry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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")
Expand Down
159 changes: 159 additions & 0 deletions internal/registry/client_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}