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
11 changes: 10 additions & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh
| `exophase-client` | The `games` service's Exophase client: the games I last played since a given time, the last played first, each with its platforms, playtime and trophies, from the games list's pages until one ends before that time, four at most; a 403 from Cloudflare's challenge says so. The second of `games-exophase`'s eleven steps (decision 301) | Done (#172) |
| `games-cache` | The `games` service polls Exophase only on request: the first request waits for the first poll, later ones get the latest list at once and start a poll in the background once the list is an hour old; after a failed poll the last good list stays, and the next poll waits 5 minutes, twice as long after each failure in a row, up to 6 hours. The third of `games-exophase`'s eleven steps (decision 302) | Done (#175) |
| `games-service` | A new `games` service answers the games I played in the last 14 days at `GET /games/recent`, from the Exophase profile that `BAKHYARD_EXOPHASE_PLAYER` names, digits only; it runs on 8090 under `mise run up`, and its image builds with the others'. The fourth of `games-exophase`'s eleven steps (decision 303) | Done (#178) |
| `twitch-token` | The `games` service gets a Twitch app token for IGDB with the client credentials grant, keeps it in memory until 9 tenths of its life, asks Twitch one request at a time, and forgets a token that IGDB refuses. The fifth of `games-exophase`'s eleven steps (decision 304) | Done (#181) |
| `vault-edit-sync` | Editing the vault from Bakhyard (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one and then does `copilot-planner` (decision 195) | In progress |
| `vault-plan` | The vault session's first steps: where a save goes waits on the user's answer to §12's question on how edits get back to the Mac, so the version that a save checks and the editor come first; a note's version is the SHA-256 of its bytes, sent as its `ETag`, and a save is a `PUT` that sends it back in `If-Match` (decision 400) | Done (#167) |
| `note-etag` | notes answers each note with its version as a strong `ETag`, the SHA-256 of its bytes, and a matching `If-None-Match` with a 304 (decision 400) | Done (#169) |
Expand Down Expand Up @@ -1271,7 +1272,7 @@ Every item is pending unless it says done. The service an item lands in is in br
- `exophase-client`: the `games` service's Exophase client reads the games I last played since a given time from the games list's JSON, with an honest user agent (decision 301); it never fetches or stores a profile's HTML. `tls-client` only if challenges start. Done (#172).
- `games-cache`: the service fetches on request: the first request waits for the first fetch, later ones get the latest list at once and start a fetch in the background once it's an hour old; after a failure, the last good list stays and the next fetch backs off (decision 302). Done (#175).
- `games-service`: a new `games` service answers the games I played in the last 14 days at `GET /games/recent` (decision 303). Done (#178).
- `twitch-token`: the service gets a Twitch app token for IGDB, keeps it and renews it before it expires.
- `twitch-token`: the service gets a Twitch app token for IGDB, keeps it and renews it before it expires (decision 304). Done (#181).
- `igdb-search`: the service's IGDB client finds a title's cover with a filtered search and an exact-name fallback (decision 68), within IGDB's 4 requests a second.
- `games-covers`: each game in the answer carries its IGDB cover; each title is matched once and the match kept.
- `homelab-games`: in the homelab repo, `games` runs on the laptop with its settings and secret, and the gateway's peers name it.
Expand Down Expand Up @@ -1773,6 +1774,14 @@ Decisions 300 to 399 are the `games-exophase` session's.
- **The wiring**: as `pulls`' is: `exophase.go` reads the setting and starts the polls on request, `internal/api` serves the route, with the 404 when Exophase isn't set up, and `main` holds Exophase's address, `https://api.exophase.com`, which `routes` takes, so the tests give a stand-in's (decision 300). `mise run up` runs games on 8090, and Gitea builds its image with the others'; the gateway forwards to it from `games-gateway` on.
- **Rejected**: the last 14 days as calendar days in the service's time zone, which is UTC in a container, so the list would change at a different hour on the Mac and on the laptop; trimming spaces around the id, as the Bitbucket workspaces' are: a list typed by hand needs it, one id doesn't.

304. **The Twitch token** (`twitch-token`): the lead's choices (2026-10-30), for the user to review.
- **The request**: `services/games/internal/twitch` asks Twitch's OAuth server, `https://id.twitch.tv`, for an app access token with the client credentials grant: a `POST` to `/oauth2/token` with the client ID, the secret and `grant_type=client_credentials` in a form body, as Twitch's docs show, rather than in the query string that IGDB's docs show, so the secret never shows in a URL. Each request is bounded by 10 seconds and its answer by 64 KiB.
- **Keeping the token**: in memory (decision 300), until 9 tenths of the lifetime that Twitch gives it, `expires_in`, counted from when the request began; the next call after that gets a new one. Twitch is asked only when a call needs a token, so the first search for a cover after a start asks it first.
- **One request at a time**: calls take turns through a one-slot channel, so only one asks Twitch and the others wait for its token. A call whose context ends while it waits returns the context's error at once.
- **IGDB's refusal**: `Drop` forgets the token that IGDB refused, so the next call gets a new one. When the token kept is no longer the one refused, because another call has renewed it, `Drop` changes nothing. It waits for a request under way, at most 10 seconds.
- **Failures**: a failed request changes nothing, so the next call asks again. A status other than 200 gives `get a Twitch token:` and the status, with Twitch's `message` when it gives one, such as `400 Bad Request, invalid client`; an answer with no token, or with a lifetime of 0 seconds or less, gives `read Twitch's answer:` and why. No error shows the secret or a token.
- **Rejected**: checking the token every hour at `/oauth2/validate`, which Twitch requires of apps that keep a user's session, not of an app token; renewing only when IGDB answers 401, which costs a refused search per token; asking for a token as the service starts, since only a title not matched yet needs one; a `sync.Mutex`, which a waiting call's context can't end; a user agent, which only Exophase's Cloudflare needed (decision 301).

Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-planner`.

400. **Editing notes, the first steps** (`vault-plan`, `note-etag`, `note-editor`): the lead's choices (2026-10-30), for the user to review.
Expand Down
118 changes: 118 additions & 0 deletions services/games/internal/twitch/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package twitch

import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

const (
// maxAnswer bounds the size of each answer from Twitch: many times what a token's needs.
maxAnswer = 64 << 10
// requestTimeout bounds each request for a token.
requestTimeout = 10 * time.Second
// getting starts the errors of a request for a token.
getting = "get a Twitch token"
)

// errAnswer marks the error of an answer from Twitch that holds no token that Tokens can keep.
var errAnswer = errors.New("read Twitch's answer")

// app asks Twitch for the tokens of the app whose client ID and secret it holds.
type app struct {
api *url.URL
clientID string
secret string
client *http.Client
}

// New returns the Tokens of the Twitch app with clientID and secret, which ask Twitch's OAuth
// server at api, https://id.twitch.tv, only when they need a token.
func New(api *url.URL, clientID, secret string) *Tokens {
return keep(newApp(api, clientID, secret).token)
}

// newApp returns the app with clientID and secret, which asks Twitch's OAuth server at api.
func newApp(api *url.URL, clientID, secret string) *app {
return &app{
api: api, clientID: clientID, secret: secret,
client: &http.Client{Timeout: requestTimeout},
}
}

// token asks Twitch for a new app token with the client credentials grant, whose credentials go
// in the form body, as Twitch's docs show, rather than in the URL, where errors would show them.
func (a *app) token(ctx context.Context) (string, time.Duration, error) {
form := url.Values{
"client_id": {a.clientID},
"client_secret": {a.secret},
"grant_type": {"client_credentials"},
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
a.api.JoinPath("oauth2/token").String(), strings.NewReader(form.Encode()))
if err != nil {
return "", 0, fmt.Errorf("%s: %w", getting, err)
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")

res, err := a.client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("%s: %w", getting, err)
}

defer func() { _ = res.Body.Close() }()

if res.StatusCode != http.StatusOK {
return "", 0, refusal(res)
}

return issued(res.Body)
}

// issued returns the token in body, Twitch's answer to a request that it granted, with the
// lifetime that the answer gives it.
func issued(body io.Reader) (string, time.Duration, error) {
var answer struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}

err := json.UnmarshalRead(io.LimitReader(body, maxAnswer), &answer)
if err != nil {
return "", 0, fmt.Errorf("%w: %w", errAnswer, err)
}

if answer.AccessToken == "" {
return "", 0, fmt.Errorf("%w: no access token", errAnswer)
}

if answer.ExpiresIn <= 0 {
return "", 0, fmt.Errorf("%w: the token expires in %d seconds", errAnswer, answer.ExpiresIn)
}

return answer.AccessToken, time.Duration(answer.ExpiresIn) * time.Second, nil
}

// refusal returns the error of an answer whose status isn't 200, with the reason that Twitch
// gives, such as "invalid client", when it gives one.
func refusal(res *http.Response) error {
var answer struct {
Message string `json:"message"`
}

err := json.UnmarshalRead(io.LimitReader(res.Body, maxAnswer), &answer)
if err != nil || answer.Message == "" {
return fmt.Errorf("%s: %s", getting, res.Status)
}

return fmt.Errorf("%s: %s, %s", getting, res.Status, answer.Message)
}
71 changes: 71 additions & 0 deletions services/games/internal/twitch/app_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package twitch

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"testing/synctest"
"time"
)

// issuer stands in for Twitch's OAuth server in memory, so that it answers in a bubble: each
// request gets the next token, issued for 100 seconds.
type issuer struct {
issued int
}

func (i *issuer) RoundTrip(*http.Request) (*http.Response, error) {
i.issued++
answer := fmt.Sprintf(`{"access_token":"token %d","expires_in":100}`, i.issued)

return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(answer)),
}, nil
}

// hanging stands in for a Twitch that never answers.
type hanging struct{}

func (hanging) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()

return nil, r.Context().Err()
}

func TestATokenLivesAsLongAsTwitchSays(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tokens := keep(standIn(&issuer{}).token)

wantToken(t, tokens, "token 1")
time.Sleep(90*time.Second - time.Nanosecond)
wantToken(t, tokens, "token 1")
time.Sleep(time.Nanosecond)
wantToken(t, tokens, "token 2")
})
}

func TestARequestForATokenGivesUpAfter10Seconds(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tokens := keep(standIn(hanging{}).token)
began := time.Now()

wantError(t, tokens, context.DeadlineExceeded)

if waited := time.Since(began); waited != 10*time.Second {
t.Errorf("gave up after %s, want 10s", waited)
}
})
}

// standIn returns the app whose requests go to answer instead of Twitch.
func standIn(answer http.RoundTripper) *app {
twitchApp := newApp(&url.URL{Scheme: "https", Host: "id.twitch.tv"}, "an-id", "a-secret")
twitchApp.client.Transport = answer

return twitchApp
}
166 changes: 166 additions & 0 deletions services/games/internal/twitch/app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package twitch_test

import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"testing"

"github.com/codedogapp/bakhyard/services/games/internal/twitch"
)

// secret is the stand-in app's client secret, which no error may show.
const secret = "hijklmn67890"

func TestTokensAskTwitchForAnAppToken(t *testing.T) {
address, asked := twitchStandIn(
t,
http.StatusOK,
`{"access_token":"an-app-token","expires_in":5011271,"token_type":"bearer"}`,
)
tokens := twitch.New(address, "abcdefg12345", secret)

for range 2 {
got, err := tokens.Token(t.Context())
if got != "an-app-token" || err != nil {
t.Fatalf("got %q, %v, want the token", got, err)
}
}

want := []string{"POST /oauth2/token?, Content-Type application/x-www-form-urlencoded, " +
"Accept application/json: " +
"client_id=abcdefg12345&client_secret=hijklmn67890&grant_type=client_credentials"}
if got := asked(); !slices.Equal(got, want) {
t.Errorf("got the requests %q, want %q", got, want)
}
}

func TestTokensSayWhyTwitchRefused(t *testing.T) {
for _, test := range []struct {
name, body, want string
status int
}{
{
"no such client", `{"status":400,"message":"invalid client"}`,
"get a Twitch token: 400 Bad Request, invalid client", http.StatusBadRequest,
},
{
"a wrong secret", `{"status":403,"message":"invalid client secret"}`,
"get a Twitch token: 403 Forbidden, invalid client secret", http.StatusForbidden,
},
{
"no reason", `{"status":502}`,
"get a Twitch token: 502 Bad Gateway", http.StatusBadGateway,
},
{
"not JSON", "<p>Down</p>",
"get a Twitch token: 500 Internal Server Error", http.StatusInternalServerError,
},
{
"a reason over 64 KiB", `{"message":"` + strings.Repeat("a", 64<<10) + `"}`,
"get a Twitch token: 500 Internal Server Error", http.StatusInternalServerError,
},
{
"a status that isn't 200", `{"access_token":"a","expires_in":100}`,
"get a Twitch token: 202 Accepted", http.StatusAccepted,
},
{"no token", `{"expires_in":100}`, "read Twitch's answer: no access token", http.StatusOK},
{
"no lifetime", `{"access_token":"a"}`,
"read Twitch's answer: the token expires in 0 seconds", http.StatusOK,
},
{
"a lifetime past", `{"access_token":"a","expires_in":-1}`,
"read Twitch's answer: the token expires in -1 seconds", http.StatusOK,
},
} {
t.Run(test.name, func(t *testing.T) {
address, _ := twitchStandIn(t, test.status, test.body)

token, err := twitch.New(address, "abcdefg12345", secret).Token(t.Context())
if token != "" || err == nil || err.Error() != test.want {
t.Errorf("got %q, %v, want the error %q", token, err, test.want)
}
})
}
}

func TestTokensRefuseAnAnswerTheyCannotRead(t *testing.T) {
for _, body := range []string{
"<p>Hi</p>",
`{"access_token":"` + strings.Repeat("a", 64<<10) + `","expires_in":100}`,
} {
address, _ := twitchStandIn(t, http.StatusOK, body)

token, err := twitch.New(address, "abcdefg12345", secret).Token(t.Context())
if token != "" || err == nil || !strings.HasPrefix(err.Error(), "read Twitch's answer: ") {
t.Errorf("%.20s: got %q, %v, want an error that starts %q", body, token, err,
"read Twitch's answer: ")
}
}
}

func TestTokensSayTwitchIsOutOfReach(t *testing.T) {
server := httptest.NewServer(http.NotFoundHandler())
server.Close()

address, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}

token, err := twitch.New(address, "abcdefg12345", secret).Token(t.Context())

want := `get a Twitch token: Post "` + server.URL + `/oauth2/token": `
if token != "" || err == nil || !strings.HasPrefix(err.Error(), want) ||
strings.Contains(err.Error(), secret) {
t.Errorf("got %q, %v, want an error that starts %q, without the secret", token, err, want)
}
}

// twitchStandIn starts a stand-in Twitch that answers each request with status and body, and
// returns its address and a function that returns the requests it got: each one's method, path,
// query, content type, accepted type and form.
func twitchStandIn(t *testing.T, status int, body string) (*url.URL, func() []string) {
t.Helper()

var (
lock sync.Mutex
requests []string
)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lock.Lock()
defer lock.Unlock()

err := r.ParseForm()
if err != nil {
t.Error(err)
}

requests = append(requests, fmt.Sprintf("%s %s?%s, Content-Type %s, Accept %s: %s",
r.Method, r.URL.Path, r.URL.RawQuery, r.Header.Get("Content-Type"),
r.Header.Get("Accept"), r.PostForm.Encode()))

w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
t.Cleanup(server.Close)

address, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}

return address, func() []string {
lock.Lock()
defer lock.Unlock()

return slices.Clone(requests)
}
}
Loading
Loading