1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# Agents
## Project overview
Rank King is a webapp for ranking items along multiple axes through pairwise comparisons, powered by OpenSkill (Plackett-Luce). Users create lists of items, define axes to rank along, then do head-to-head matchups. Ratings are derived from results.
## Tech stack
- Ruby 4, Roda (routing), Sequel (ORM), SQLite
- Phlex for views (pure Ruby HTML)
- Gloss (https://github.com/kejadlen/gloss) for the design system, vendored
- Puma web server, Rack
- Minitest for tests
- Steep for type checking, against inline RBS comments
## Repository layout
```
config.ru — Rack entrypoint (expects the proxy's Remote-User header)
demo.ru — config.ru plus StubLogin and sample lists, for dev and the Fly apps
Gemfile — dependencies
Rakefile — rake tasks (test, dev)
db/migrate/ — Sequel migrations
lib/rank_king/ — app code
config.rb — Config data class, reads from ENV
db.rb — Sequel connection + migration runner
models.rb — Sequel models (User, List, Item, Axis, Result)
inner_tube.rb — YouTube client: InnerTube, signed with a pasted browser cookie
sources.rb — Sources.providers: a list's source_kind names one of them
sources/ — one class per source, each with the Provider that makes it
sync.rb — reconciles a list's items with a fresh snapshot from a fetching source
proxy_auth.rb — reads the login from Remote-User
seed.rb — sample lists for the stub login (demo.ru)
stub_login.rb — writes a fixed login where there is no proxy (demo.ru)
web.rb — Roda routes
views/ — Phlex view components
lib/open_skill/ — OpenSkill rating library (pure Ruby)
test/ — Minitest tests
sig/ — RBS for what inline comments cannot express
Steepfile — type checking config
public/ — static assets
vendor/gloss/ — vendored Gloss CSS and JS (refresh with rake gloss:vendor)
css/app.css — Rank King's layout over Gloss
js/app.js — axis tags, confirmations, busy buttons, new-list source fields, YouTube playlist picker
.github/workflows/ — CI, PR previews, preview reaper (GitHub Actions)
Dockerfile — production image
fly.toml — Fly config for the main demo and PR previews
.ramekin/ — agent dev container
```
## Domain model
- **User** has many Lists; identified by the login on the `Remote-User` header
- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names its provider in `Sources.providers`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a source that fetches calls `Sync.call`. A fetching source keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
- Sources: `Text` (typed in, edited by id) and `YouTube` (follows one of the owner's playlists, Watch Later included, with a youtube.com cookie pasted for each fetch and never kept; `youtube_sources` by `list_id`, `youtube_items` by `item_id`)
- **Item** belongs to a List; removed items, and items missing from the latest sync, are archived (`archived_at`) and hidden everywhere
- **Axis** belongs to a List, has many Results
- **Result** belongs to an Axis, a winner (Item), and a loser (Item); a `tie` result is a draw between the two
## Commands
| Task | Command |
|------|---------|
| Type check and test | `bundle exec rake` |
| Run tests | `bundle exec rake test` |
| Type check | `bundle exec rake steep` |
| Run dev server | `bundle exec rake dev` (uses `fd \| entr -r rackup demo.ru`, keeping data in `db/dev.db` and a fixed `SESSION_SECRET`; delete the file to reset) |
| Refresh Gloss | `bundle exec rake gloss:vendor GLOSS=/path/to/gloss` |
| Start server | `bundle exec rackup` (behind the proxy) or `bundle exec rackup demo.ru` (stub login) |
## CI and deployment
- `ci.yml` runs `rake` and audits workflows with zizmor on every push and PR.
On main it then builds the image, pushes it to GHCR, cuts a GitHub release
named after the image tag, and deploys that image to Fly (`rank-king`).
- `preview.yml` deploys each PR to its own Fly app and destroys it on close;
`reap-previews.yml` cleans up any that were missed.
- Needs a `FLY_API_TOKEN` secret, available to the `demo`, `pr-*`, and
`preview-reaper` environments.
- Workflow actions are pinned by commit with a tag comment naming that
commit, never a branch. Renovate keeps them, gems, and the Docker base
image current.
## Environment variables
- `DATABASE_URL` — SQLite path (default: `:memory:`)
- `SESSION_SECRET` — session signing key (auto-generated if unset)
Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`. Clients that
talk to the outside world live on `Config`; `Sources.providers` hands
each provider only the clients it needs. The YouTube tests subclass the app
to override `config` with an `InnerTube` subclass whose `request`
answers without the network.
## YouTube
YouTube is read through InnerTube (`youtubei/v1/browse`), the private
API youtube.com's own pages call, not OAuth and the Data API: an
Advanced Protection account cannot grant OAuth to an unverified client,
and the Data API never returns Watch Later. Requests carry the user's
pasted Cookie header and a `SAPISIDHASH` Authorization signed from it.
The cookie is pasted on the new-list form, where app.js posts it to
`/youtube/playlists` for the picker, and again in each sync dialog;
rank-king never stores it.
- `FEplaylist_aggregation` lists playlists (Watch Later and Liked
videos included); `VL<playlist id>` browses one.
- A signed-out cookie still gets `200`, with `logged_in` `0` and an
error alert, so `InnerTube#check` raises rather than let a sync
archive the whole list.
- `InnerTube::CLIENT`'s `clientVersion` may need bumping if YouTube
starts refusing it.
## Conventions
- `$LOAD_PATH` is set to `lib/` in both `config.ru` and `Rakefile`
- Sequel migrations live in `db/migrate/` with numeric prefixes
- Models use `Sequel::Model` with `timestamps` plugin
- Views are Phlex components under `lib/rank_king/views/`
- CSRF protection is on for all POST routes
- Markup follows Gloss' contracts (`_components/<name>.md` in the Gloss
repo): bare elements, ARIA roles, and `data-*` variants, with no class
beyond Gloss' own. `app.css` reads only `--gl-*` tokens and lays
components out rather than restyling them.
- Every screen opens with one header row (back, title, then the
screen's own actions). List actions go in the list's ⋯ menu and list
options in its settings form, so a new feature adds an entry there
rather than a new button or screen.
- A list's source is fixed when it is made. `Sources.providers` lists
the providers the new-list form offers, and each provider's `title`
names it there and in settings. Its sync dialog opens from the list's ⋯
menu. Each provider also renders its own fields on the new-list form
(`fields`), sets the new list up from them (`create`), mounts any
routes those fields post to (`routes`), and opens a list's source
(`source`).
- Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
- Lists are private to their owner (404 for anyone else)
- Config is a frozen `Data.define` value object
- Paths are `Pathname`s, not strings built with `File`/`Dir`
(`Pathname(__dir__).join(...)`, `#exist?`, `#read`). Convert with
`to_s` only at a boundary that wants a String, such as `$LOAD_PATH`,
`Rack::Builder.parse_file`, or `Sequel::Migrator.run`.
- Types are inline RBS comments (`#:` before a method, `# @rbs` for the
rest), checked by `rake steep`. `sig/` holds only what that syntax
cannot say, each file saying why: gems with no signatures (Sequel,
Roda), Data classes, the models (no constant superclass), and the
Phlex views, which the Steepfile ignores.
- `rake steep` needs a UTF-8 locale: under a C locale the em dashes in
comments are invalid bytes and the check hangs.