Author SHA1 Message Date
mr.zeroandClaude Opus 5 066451f31c warp_engine 0.5.1: the device_grants migration collided with the host's
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/tag/woodpecker Pipeline failed
`db:migrate` on the catalog API stopped before running anything:

    Multiple migrations have the version number 20260819000001.

The engine appends its `db/migrate` to the host's migration paths instead of
copying migrations in, so engine and host share one version namespace. Both had
picked 20260819000001 on the same day by the same habit — the engine for
device_grants, apps/api for carry_the_store_config_in_the_registry — and neither
repository could see the other's number.

Worse than a clash of our own making: it takes the *host's* migrations down with
it, for the whole application, before anything runs.

device_grants is renumbered to 20260819093412 — a real second-resolution
timestamp, which is the actual defence. A round hand-written number is precisely
what another repository lands on. Nothing had run it in production, so this is a
rename rather than a data migration; a host that already applied the old version
renumbers its schema_migrations row.

The older engine migrations keep their round numbers: renumbering one that has
been deployed everywhere is worse than the risk it carries. They are named in the
new spec's grandfather list rather than excused by a rule that would also let the
next one through.

That spec found a second thing, older than this change: the install template
creates `application_tokens`, and so does one of our own migrations — so a fresh
host runs CREATE TABLE twice and has to delete the block from its generated copy
by hand, which is exactly what teletype-orbit's migration header describes. I had
just made it worse by putting device_grants in the template too; that is out
again, and the template says why. `application_tokens` is grandfathered and left
for a change that is not a hotfix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:29:53 +02:00
mr.zeroandClaude Opus 5 65f337bcf4 README: the registry example named the wrong org
ci/woodpecker/push/woodpecker Pipeline was successful
`api/packages/tools/rubygems` was right until the org reshuffle moved the engine
to `engines`, and it has been copy-pasteable-but-broken since. The gemspec's
allowed_push_host and the CI's `gem push --host` both say `engines`; only the
README did not.

While there: pin the example to `~> 0.5` and say why the source is block-scoped.
A second *global* source leaves Bundler unable to say which gem came from where.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:20:53 +02:00
mr.zeroandClaude Opus 5 c1741f64f3 WarpEngine: let the host say who a browser is
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
The access policy asks who the caller is, and until now only a bearer token
could answer. That is what a desktop client carries — but a person clicking a
download link on the site carries a session instead, and the engine has no idea
what a session is. So a host that gated its catalog found its own signed-in
visitors refused at /api/download, which is a regression the shadowed route used
to hide.

c.subject_resolver is a callable taking the Rack request and returning the
host's subject: `->(request) { request.env["warden"]&.user }` for a Devise app.
Unset — every deployment today — a non-bearer request stays anonymous, exactly
as before. A resolver that raises is logged and treated as anonymous, because a
broken one turning every read into a 500 is worse than an anonymous request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:36:30 +02:00
mr.zeroandClaude Opus 5 9f31c85256 WarpEngine 0.5.0: a catalog that can say a title is not yours
ci/woodpecker/push/woodpecker Pipeline was successful
A desktop client reading /api/software had no way to learn that a title costs
money. There was nothing in the response to say so, no way to sign in, and no
way to be told "you do not own this" — so a store with paid titles could only
hand the client a 403 at download time and let it guess why.

The fix belongs here rather than in the client. A client serves more than one
store, so anything it knows about a particular one has to arrive from that
store's own API; a rule compiled into the client is a rule that breaks every
other catalog it reads. Three seams, each following the storage adapter's
shape — documented contract, default that is byte for byte the old behaviour,
one config key to replace it:

- **access policy** — visible_software_scope / access_for / authorize_download.
  Every catalog entry now carries an `access` block (gated, entitled, price,
  purchaseUrl, webUrl) and both /api/download and /file/* ask before serving.
  The vocabulary is deliberately generic: a word from one host's domain would
  make every client that reads it specific to that host.
- **client sign-in** — the device authorization grant (RFC 8628), over the
  host's own user model. The approval page stays the host's, because approving
  needs a session and HTML. Tokens are ApplicationTokens with a `catalog`
  scope, so publishing and reading stay separable.
- **service descriptor** — GET /api/service says what this deployment is and
  whether it has a sign-in at all, which is how a client stops guessing.

With no policy and no subject class configured — every deployment today — the
API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and
the 187 pre-existing examples pass untouched.

A policy that raises is treated as a refusal, not permission. An artifact
served because the gatekeeper crashed is the one failure mode this must not
have, so a broken policy empties the catalog and denies the download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:26:28 +02:00
mr.zero f8dee01c81 warpengine-client.png upgrade 2026-08-19 08:45:25 +02:00
mr.zeroandClaude Opus 5 021f2b9b07 A store record is a name and a catalog
Both extras go. `config` was added this morning on the idea that the registry should
say how each store behaves; that was wrong. The configuration is fixed per installed
client — the client carries its own store engine and knows its own machine — so a copy
here was a second authority over decisions the client had already made correctly,
including which directories it may delete from. Keeping two stores on one machine apart
is a subfolder, and the client derives that itself.

`store_repository_url` goes with it. The store engines it pointed at no longer exist, and
a URL nobody follows is a URL that goes stale.

The public stores page loses its desktop card for the same reason: it advertised a
`curl … | sh` for a repository that is gone, and an ordinary computer is served by the
app in the section above it. `/desktop` now lands on that app rather than on a device
tab, so the old URL still means what someone typing it wants.

Unrelated but in the way: the dead `engines` list in that page has been failing
`vue-tsc` on master, so the frontend could not be built to check any of this. Removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 07:19:52 +02:00
mr.zeroandClaude Opus 5 9255a11254 Carry a store's configuration in the registry record
A store's `config.json` lived in a repository the desktop client fetched over HTTP,
which made a store's behaviour depend on a second thing existing and staying
reachable. The registry already answers what a store *is*; it now answers how it
behaves too, in the same shape that file had, so this record is the one source of
truth and a store can be configured from the admin alone.

`storeRepositoryUrl` stays, demoted to a pointer for a person — where the store's
own repository is, when it has one. Clients released before this field still fetch a
`config.json` from it, so nothing has to move at once.

The column is nullable because a store that configures nothing is still a store: the
client falls back to the engine's built-in defaults, which need only a name and a
catalog. The admin edits it as JSON text through a pair of accessors, so the column
holds real JSON and invalid input comes back with the text kept and a message rather
than a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 06:43:09 +02:00
mr.zero 581d3c4371 new StoresIndexPage fix 2 2026-08-18 21:34:27 +02:00
mr.zero 7c0f1479b4 new StoresIndexPage fix 2026-08-18 21:33:08 +02:00
mr.zero a99834abf0 new StoresIndexPage 2026-08-18 21:25:40 +02:00
mr.zeroandClaude Opus 5 7ea8303b1a The pipeline admin form could never save
ci/woodpecker/push/woodpecker Pipeline was successful
`ActiveAdmin.register WarpEngine::Pipeline` declared no `permit_params`, so every edit
handed unpermitted attributes to the model and Rails raised ForbiddenAttributesError. That
is not new: the form has been unable to save for as long as it has existed. My flash
message on `update` sat at the top of the traceback and made it look like the cause, which
it was not — and it is gone anyway, because overriding an ActiveAdmin action to say
something is a poor trade for what it can break. The move is written to the log instead.

Adding a spec that would have caught it, in the host app, because that is where the
ActiveAdmin instance lives: it signs in, PUTs the form, and checks both that the record
saves and that the software link moves off the pipeline that had it. Driven the same way
by hand against the development database first — 302, the link moved, the previous holder
left without one.

The engine's other admin resources were checked for the same omission: downloads and
releases are read-only and the file manager posts to its own routes, so pipelines was the
only one affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:41:16 +02:00
mr.zeroandClaude Opus 5 a0a7ff63e1 Do not let a response header take the API down
The version header was set from WarpEngine::VERSION_HEADER, a constant introduced in the
same commit. The deploy that followed ran these controllers with an older `lib/`, so the
before_action raised NameError on every request and every engine endpoint answered 500 —
the catalog, the images, the file server and the config extension Woodpecker calls, which
is how it surfaced: a pipeline could no longer fetch its own configuration.

The controller now spells the header name out. A response header is not worth a dependency
that can take the API down when one half of a deploy is older than the other, and the
constant remains the documented name with a spec holding the two in step.

Confirmed in production mode against the same code that failed: 200 with the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:20:39 +02:00
mr.zeroandClaude Opus 5 3d5ba3f31d A version header on every WarpEngine response, and a movable pipeline link
ci/woodpecker/push/woodpecker Pipeline was successful
Every WarpEngine API response now carries `WarpEngine-Version`, so a client can branch on
the engine's age without a round trip to ask. Set in a before_action rather than after:
`rescue_from` never reaches an after_action, and a client needs the version most when
something came back wrong. The name lives in `WarpEngine::VERSION_HEADER`. The host's own
endpoints — the store registry — do not carry it, because they are not the engine.

A software has one pipeline, and the newest assignment now wins. Two pipelines pointing at
the same software was not an error the database caught; it was a link that silently did
nothing, with the software still showing whichever row came first. Assigning a software
another pipeline holds therefore moves it, the admin says which pipeline it was taken
from, and `Pipeline#software_taken_from` carries that for anything else that cares.
Deliberately a callback and not a unique index: rows here are soft-deleted, and a unique
index counts deleted rows, so a pipeline removed last year would block its software from
ever being linked again.

The engine is 0.4.0. The site's /stores page and its screenshot follow the client's new
name, and the shot is a fresh one showing the greyed-out titles the client now lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:46:29 +02:00
mr.zero bf10aa87e8 warpengine-client.png 2026-08-18 18:34:11 +02:00
mr.zero f8c9cfe996 new stores page 2026-08-18 18:12:46 +02:00
mr.zeroandClaude Opus 5 466aeac6ca A store registry record needs no repository
The client only ever took identity from a store repository — a slug, a name, a catalog —
and the store engine's own defaults cover everything else: the host-to-asset mapping, the
install modes, the platforms, the behaviour. So `store_repository_url` is now optional:
nullable in the schema, no presence validation, the format check only when a value is
given, and the serializer answers null rather than an empty string, because the client
branches on its absence.

Adding a store is therefore a row with two fields filled in. Given a repository the
client still reads its config.json, and that file remains the authority on how the store
behaves — the admin form and the endpoint's documentation say so.

The frontend's /stores page gains a section of its own for the graphical client on the
desktop tab: what it does, that it sets the store up itself, that it is the way in on
Windows where `curl … | sh` does not exist, and links to the releases, the repository and
the documentation — now under stores/warp-engine-client, which is where that repository
lives after the rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:53:33 +02:00
mr.zeroandClaude Opus 5 5517133e25 A store registry, so a client is not told which store to install
The desktop graphical client had our store's config URL compiled into it. That is
backwards: which stores exist for a catalog is something the site knows, and a
client should be able to ask. `GET /api/stores` answers, and the client picks from
what comes back.

A `Store` row is three fields — name, catalog_url, store_repository_url — with an
ActiveAdmin panel, a Blueprinter serializer in the catalog's camelCase, and a
service+controller pair following the events and members shape. `db/seeds.rb`
creates our own record, so a fresh database serves a working registry.

The endpoint is **public**, which is the point: the client runs on someone's
laptop before any store exists and has nobody to log in as. It exposes three URLs
that are public anyway.

This deliberately does not live in WarpEngine. The engine serves one catalog and
has no business knowing who ships stores for it; a registry of stores is a
property of this site, not of the catalog software. Anyone mounting WarpEngine can
keep their own list, or none.

16 model and controller examples pass. Worth noting for the next person who runs
them: the specs need RAILS_ENV=test, as the README says — without it rspec runs in
the development environment, host authorization rejects Rack::Test's hostname, and
every request spec fails with a 403 and an HTML body that looks nothing like a
routing problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:12:01 +02:00
mr.zero 4dccb9a815 Store resource 2026-08-18 13:04:21 +02:00
mr.zeroandClaude Opus 5 c85148d238 Offer the graphical client on the desktop tab
The desktop store now has a window, and it is the only one of the three that
does — so the download link and the sentence explaining it appear on that tab
alone. On Windows this is the way in, since the shell installer needs a shell
the platform does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:21:56 +02:00
mr.zeroandClaude Opus 5 201e422be6 Put this computer first on the stores page
The page had two device tabs, both for hardware most visitors do not own. The
desktop store makes the third one the likeliest answer, so "This computer" is
added and selected by default, and /desktop redirects to it the way /batocera and
/retroarch already do.

Device names now come from i18n rather than the component: "Batocera" and
"RetroArch" are product names either way, but "This computer" has to be
translatable. Every key the page uses was checked to exist in both locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:00:39 +02:00
mr.zeroandClaude Opus 5 3eedabce43 One /stores page for both stores, with a device chooser
The Batocera store had /batocera to itself. A RetroArch store now serves the same
cartridges on every other machine, and copying the page would have duplicated
everything the two have in common — the framing, the platform table, the engine
links — so they share one page and a Batocera/RetroArch chooser. Only the install
command, the CLI and the uninstall line change with the tab.

/batocera and /retroarch both redirect here with the matching tab preselected,
so old links keep working and the name someone guesses after reading "RetroArch
store" lands somewhere useful. `?device=` makes a link to either half shareable.

The page also documents uninstalling, which it never did, and the five command
blocks share one CommandBlock component instead of repeating the copy button.
The i18n `batocera` block becomes `stores` in both locales; every key the page
uses was checked to exist in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:33:19 +02:00
mr.zero f1536d8b39 woodpecker upgrade 2026-08-17 08:08:17 +02:00
mr.zero ce904489d4 remove ▶ chars 2026-08-17 08:00:46 +02:00
mr.zeroandClaude Opus 5 22ee724c09 Run the engine specs before mirroring and publishing
The engine's 180 specs were never run by CI: the mirror repo is
generated by a subtree split, so a pipeline committed there would be
overwritten on the next sync, and the monorepo's own pipeline only
mirrored and published. A broken engine could reach the gem registry.

The test now gates both — it runs on the same trigger the mirror does
(a change under libs/ruby/warp_engine), and the steps after it only run
if it passes. The specs need MySQL, hence the service, and a prepared
test database: on a fresh database maintain_test_schema! reports the
engine's own migrations as pending instead of loading the schema.

Verified by running the step as written in a clean ruby:3.2 container
against a fresh mysql:8 — 180 examples, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:07:56 +02:00
mr.zeroandClaude Opus 5 6b24707c8d Build bundled Phaser projects with their own toolchain
ci/woodpecker/push/woodpecker Pipeline was successful
The step assumed one project shape: plain sources concatenated with cat
and Phaser pulled from a CDN. trickster-tiles is the other shape — Vite
plus TypeScript, built with `tsc && vite build` — so `cat src/*.js`
found nothing to bundle and the build failed. The earlier guard on the
syntax check fixed only the first symptom of that mismatch.

A project with a build script now runs npm ci && npm run build and is
packaged from its own dist/, which already contains index.html and the
hashed assets. Plain projects keep the existing path untouched.

The bundler's base has to be relative, since games are served out of
/file/<name>-<version>/; the step fails loudly if the build leaves no
dist/index.html rather than shipping an empty zip.

Verified in the phaser-builder image against trickster-tiles: a 344 kB
package with index.html at the root and ./assets/ references.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:12:12 +02:00
70 changed files with 2700 additions and 300 deletions
+30
View File
@@ -21,7 +21,37 @@ clone:
partial: false
depth: 0 # a subtree splithez teljes history kell
services:
- name: mysql
image: mysql:8
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: warp_engine_test
steps:
# A motor tesztje kapuzza a tobbit: ha bukik, se a tukrozes, se a gem
# kiadasa nem fut le. A mirror repoba nincs ertelme CI-t tenni, mert azt a
# subtree split minden alkalommal feluliria.
test-engine:
image: ruby:3.2
environment:
RAILS_ENV: test
DB_HOST: mysql
commands:
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
- cd libs/ruby/warp_engine
- bundle install --jobs 4
- |
echo "==> Varakozas a mysql-re"
for i in $(seq 1 60); do
if mysqladmin ping -h mysql --silent 2>/dev/null; then break; fi
sleep 2
done
- |
echo "==> Teszt adatbazis elokeszitese"
bundle exec rake app:db:test:prepare
- bundle exec rspec
split-mirror:
image: alpine/git
environment:
+41 -2
View File
@@ -21,8 +21,47 @@ The API is split in two layers:
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
wiki proxy, RSS feeds, Devise/ActiveAdmin authentication, theming and assets.
It consumes WarpEngine as a path gem and mounts it at `/`.
the store registry, wiki proxy, RSS feeds, Devise/ActiveAdmin authentication,
theming and assets. It consumes WarpEngine as a path gem and mounts it at `/`.
## The store registry
`GET /api/stores` lists the stores a client can install from. The desktop
graphical client reads it on first run, which is why it is **public**: a client has
nobody to log in as.
```json
[
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }
]
```
A `Store` row has two required fields — `name` and `catalog_url` — plus an
**optional** `store_repository_url`, maintained from **ActiveAdmin ▸ 🛒 Stores**;
`db/seeds.rb` creates our own.
**A store does not need a repository.** The store engine's own defaults already
cover the host-to-asset mapping, the install modes, the platforms and the
behaviour; what they cannot know is identity — a slug, a name and a catalog — and
that is what this row carries. With no repository the client derives the slug from
the catalog host, writes a small config and installs. Given one, it reads that
repository's `config.json` and points it at `catalog_url`, and that file remains the
authority on how the store behaves; a repository without a config file is treated
as no repository at all.
Every WarpEngine API response also carries a `WarpEngine-Version` header — the registry
above is the host's own endpoint and does not, because it is not part of the engine.
This lives in the host app **on purpose, not in WarpEngine**. The engine serves
one catalog and has no business knowing which stores exist for it; who ships a
store for a catalog is a property of the site.
| | |
|---|---|
| Model | `apps/api/app/models/store.rb` |
| Endpoint | `apps/api/app/controllers/api/stores_controller.rb` |
| Admin | `apps/api/app/admin/stores.rb` |
| Client | [`warp-engine-client`](https://git.teletypegames.org/stores/warp-engine-client) |
## Development environment
+1 -1
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.3.0)
warp_engine (0.5.0)
apipie-rails
blueprinter
rails (>= 8.0)
+45
View File
@@ -0,0 +1,45 @@
ActiveAdmin.register Store do
permit_params :name, :catalog_url
menu priority: 5, label: "🛒 Stores"
index do
selectable_column
id_column
column :name
column :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
column :updated_at
actions
end
filter :name
filter :catalog_url
show do
attributes_table do
row :id
row :name
row :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
row :created_at
row :updated_at
end
para do
"Listed by GET /api/stores, which the graphical client reads on first run. A name " \
"and a catalog are the whole record: the client carries its own store engine " \
"and configures itself from this much, deriving the store's slug from the " \
"catalog host."
end
end
form do |f|
f.inputs do
f.input :name, hint: "What the client shows in its store picker"
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
end
f.actions
end
end
@@ -0,0 +1,23 @@
class Api::StoresController < ApiController
resource_description do
short "Stores"
end
api :GET, "/api/stores", "List the stores a client can install from"
desc <<~DESC
The registry the graphical desktop client reads on first run: which catalogs
exist. Public on purpose: a client has nobody to log in as.
A name and a catalog URL are the whole record. How a store behaves is fixed per
installed client it carries its own store engine and knows its own machine so
the registry says what a store *is* and nothing about how it works.
DESC
returns code: 200, desc: "Array of stores" do
property :name, String, desc: "Display name of the store"
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
end
def index
render json: StoreService.new.index
end
end
+25
View File
@@ -0,0 +1,25 @@
# A store a client can install from: a name and a WarpEngine catalog.
#
# That is the whole record, and deliberately so. A client takes identity from it — the
# name, the catalog, and a slug derived from the catalog host — and everything else from
# the store engine it carries. How a store behaves is fixed per installed client, which
# knows its own machine; a copy of it here would be a second authority over decisions
# the client has already made, including which directories it may delete from.
#
# This is deliberately not part of WarpEngine. The engine serves one catalog and has no
# business knowing which stores exist for it; the registry is a property of this site,
# which is what the graphical client asks.
class Store < ApplicationRecord
URL = %r{\Ahttps?://\S+\z}
validates :name, presence: true
validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
default_scope { where(deleted_at: nil) }
scope :ordered, -> { order(:name) }
def self.ransackable_attributes(auth_object = nil)
%w[id name catalog_url created_at updated_at]
end
end
@@ -0,0 +1,8 @@
class StoreSerializer < Blueprinter::Base
include WarpEngine::TimestampFields
field :name
# camelCase, as the catalog's own payloads use — one convention for a client
# that reads both.
field(:catalogUrl) { |store| store.catalog_url }
end
+5
View File
@@ -0,0 +1,5 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.ordered)
end
end
+1
View File
@@ -7,6 +7,7 @@ Rails.application.routes.draw do
get "swagger", to: "swagger#index"
get "events", to: "events#index"
get "members", to: "members#index"
get "stores", to: "stores#index"
get "wiki/pages", to: "wiki#index"
get "rss/blog", to: "rss#blog"
get "rss/releases", to: "rss#releases"
@@ -0,0 +1,19 @@
class CreateStores < ActiveRecord::Migration[8.1]
def change
create_table :stores, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
# A store is a WarpEngine catalog plus the repository that configures a
# client for it. The registry lives here rather than in the engine: the
# engine serves one catalog and knows nothing about who ships stores for it.
t.string :name, null: false
t.string :catalog_url, null: false
t.string :store_repository_url, null: false
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3
t.index :deleted_at, name: "idx_stores_deleted_at"
t.index :name, name: "idx_stores_name"
end
end
end
@@ -0,0 +1,10 @@
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
# A store repository is now optional. The client only ever needed identity from
# it — a name, a catalog and a slug — and the engine's own defaults cover
# everything else, so a record with a catalog URL is a complete store. A
# repository is still honoured when there is one: it stays the authority on how
# that store behaves.
def change
change_column_null :stores, :store_repository_url, true
end
end
@@ -0,0 +1,16 @@
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
# The store's own configuration moves into this record.
#
# It used to live as a `config.json` in a repository the client fetched over HTTP,
# which made a store's behaviour depend on a second thing existing and staying
# reachable. The registry already answers what a store *is*; carrying how it behaves
# in the same record makes this the one source of truth, and lets a store exist with
# no repository at all — which is the ordinary case now that the store engine ships
# inside the client.
#
# Nullable, because a store that configures nothing is still a store: the client
# falls back to the engine's built-in defaults, which need only a name and a catalog.
def change
add_column :stores, :config, :json
end
end
@@ -0,0 +1,18 @@
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
# Both extras go. A store record is a name and a catalog, and nothing else.
#
# `config` was added earlier today on the idea that the registry should say how each
# store behaves. It should not: the configuration is fixed per installed client — the
# client carries it and knows its own machine — so a copy on the server was a second
# authority over decisions the client had already made correctly, including where it
# may delete. Keeping two stores on one machine apart is a subfolder, which the client
# derives itself.
#
# `store_repository_url` goes for the same reason it stopped being read: the store
# engines it pointed at do not exist any more, and a URL nobody follows is a URL that
# goes stale.
def change
remove_column :stores, :config, :json
remove_column :stores, :store_repository_url, :string
end
end
+29 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
ActiveRecord::Schema[8.1].define(version: 2026_08_19_120000) do
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
@@ -45,6 +45,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "application_token_id", unsigned: true
t.datetime "approved_at", precision: 3
t.string "client_name", limit: 128
t.datetime "created_at", precision: 3
t.datetime "denied_at", precision: 3
t.string "device_code", limit: 64, null: false
t.datetime "expires_at", precision: 3, null: false
t.string "issued_token", limit: 64
t.bigint "subject_id", unsigned: true
t.string "subject_type", limit: 128
t.datetime "updated_at", precision: 3
t.string "user_code", limit: 16, null: false
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
t.index ["expires_at"], name: "idx_device_grants_expires_at"
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -281,6 +299,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "catalog_url", null: false
t.datetime "created_at", precision: 3, null: false
t.datetime "deleted_at", precision: 3
t.string "name", null: false
t.datetime "updated_at", precision: 3, null: false
t.index ["deleted_at"], name: "idx_stores_deleted_at"
t.index ["name"], name: "idx_stores_name"
end
add_foreign_key "admin_users", "members"
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
+7
View File
@@ -15,3 +15,10 @@ end
m.avatar_filename = attrs[:avatar_filename]
end
end
# The store registry the graphical desktop client reads. Our own catalog is the
# first record; anyone running this site would add their own the same way, from
# the admin panel or here.
Store.find_or_create_by!(name: "Teletype Games") do |store|
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
end
@@ -0,0 +1,36 @@
require "rails_helper"
RSpec.describe Api::StoresController, type: :request do
describe "GET /api/stores" do
it "lists stores without a login, in name order" do
create(:store, name: "Zed Games", catalog_url: "https://zed.example")
create(:store, name: "Apex Games", catalog_url: "https://apex.example")
get "/api/stores"
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
end
it "answers with the fields a client needs, camelCased" do
create(:store)
get "/api/stores"
store = JSON.parse(response.body).first
# Exactly two fields: a name and a catalog are the whole record, and a client
# that starts reading a third would be reading something this site no longer says.
expect(store.keys).to contain_exactly("name", "catalogUrl")
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
end
it "leaves out soft-deleted stores" do
create(:store, name: "Gone").update!(deleted_at: Time.current)
get "/api/stores"
expect(JSON.parse(response.body)).to be_empty
end
end
end
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :store do
name { "Teletype Games" }
catalog_url { "https://teletypegames.org" }
end
end
+28
View File
@@ -0,0 +1,28 @@
require "rails_helper"
RSpec.describe Store, type: :model do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:catalog_url) }
it "rejects a catalog url that is not http(s)" do
store = build(:store, catalog_url: "git@example.org:thing.git")
expect(store).not_to be_valid
expect(store.errors[:catalog_url]).to include("must be an http(s) URL")
end
describe ".ordered" do
it "lists stores by name" do
later = create(:store, name: "Zed Games")
first = create(:store, name: "Apex Games")
expect(Store.ordered).to eq([first, later])
end
end
it "hides soft-deleted stores" do
store = create(:store)
store.update!(deleted_at: Time.current)
expect(Store.all).to be_empty
end
end
@@ -0,0 +1,56 @@
require "rails_helper"
require "warden/test/helpers"
# The pipeline admin form could not save at all: the resource never declared
# `permit_params`, so ActiveAdmin handed unpermitted attributes to the model and Rails
# raised ForbiddenAttributesError on every edit. No model spec could have caught that —
# the fault was one layer up — so the check belongs here, where the host's ActiveAdmin
# instance actually runs.
RSpec.describe "Admin pipelines", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "pipelines-spec@example.org", password: "password123") }
let(:software) { create(:software) }
let!(:holder) do
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_001, repo_owner: "spec", repo_name: "holder",
platform: "tic80", software: software)
end
let!(:taker) do
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_002, repo_owner: "spec", repo_name: "taker",
platform: "tic80")
end
before do
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after { Warden.test_reset! }
# This app keeps forgery protection on in the test environment, and a request spec has
# no rendered form to take a token from. The token is not what is under test here, so it
# is switched off for the duration and put back afterwards.
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
it "saves the form" do
put "/admin/pipelines/#{taker.id}", params: { pipeline: { platform: "godot" } }
expect(response).to have_http_status(:found)
expect(taker.reload.platform).to eq("godot")
end
it "moves the software off the pipeline that had it" do
put "/admin/pipelines/#{taker.id}",
params: { pipeline: { platform: "tic80", software_id: software.id } }
expect(response).to have_http_status(:found)
expect(taker.reload.software).to eq(software)
expect(holder.reload.software).to be_nil
expect(software.reload.pipeline).to eq(taker)
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

+28 -15
View File
@@ -30,7 +30,7 @@ export default {
startsOn: 'Starts on:',
upcomingEvents: 'Upcoming Events',
featuredGame: 'Featured Game',
playInBrowser: 'Play in Browser',
playInBrowser: 'Play in Browser',
viewProjectDetails: 'View Project Details',
latestFromYoutube: 'Latest from YouTube',
visitChannel: 'Visit Channel ↗',
@@ -133,7 +133,7 @@ export default {
about: 'About',
latestStable: 'Latest Stable',
released: 'Released:',
playNow: 'Play Now',
playNow: 'Play Now',
download: 'Download',
source: 'Source',
docs: 'Docs',
@@ -198,23 +198,36 @@ export default {
title: 'Build Matrix',
subtitle: 'Which engines build for which platforms.',
},
batocera: {
title: 'Batocera Store',
subtitle: 'Our catalog, straight on your retro box.',
lead: 'ttg-batocera-store puts our catalog on Batocera, the plug-and-play retro gaming distribution. It pulls our games into the box\'s ROM folders together with box art and EmulationStation metadata, and can be re-run any time from the Ports menu to pick up new releases. Python 3 standard library only — nothing to install alongside it.',
repo: 'Git repository',
stores: {
title: 'Stores',
subtitle: 'Our games on your own machine, kept up to date.',
viewClient: 'WarpEngine Client',
viewOthers: 'Others',
clientDesc: 'The app: the catalog as a grid of cards, one click to install a game into your application menu, one to play it. It sets the store up itself on first run, and on Windows it is the way in.',
clientDownload: 'Download',
clientPlatforms: 'Linux, macOS and Windows · needs Python 3',
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
docs: 'Documentation',
batoceraProject: 'Batocera project',
repo: 'Git repository',
installTitle: 'Install',
installDesc: 'Run this over SSH on the Batocera box. It installs the client, writes the config, creates the Ports entry and runs the first sync.',
installNote: 'Then restart EmulationStation so the games show up:',
installThen: 'Then',
useTitle: 'Use it',
useDesc: 'On the device: Ports ▸ "Teletype Games Store". Over SSH the client has a small CLI:',
platformsTitle: 'What it installs',
platformsDesc: 'A catalog platform is installable when its release asset boots directly on a Batocera system. Other platforms ship web and desktop builds instead, so they are not mapped.',
engineTitle: 'Run your own store',
engineDesc: 'Nothing here is specific to us. Our store is just a config file on top of warp-engine-batocera-store, an open engine that works with any WarpEngine-based site: point it at your own catalog, give it a name and a ROM subfolder of its own, and your games land on a Batocera box the same way. Several stores can live side by side on one machine without touching each other\'s games.',
uninstallTitle: 'Remove it',
platformsTitle: 'What gets installed',
copy: 'Copy',
batocera: {
name: 'Batocera',
site: 'Batocera project',
tagline: 'The plug-and-play retro distribution',
},
retroarch: {
name: 'RetroArch',
site: 'RetroArch',
tagline: 'Anything that runs RetroArch',
},
},
code: {
title: 'Codebase',
+30 -17
View File
@@ -30,7 +30,7 @@ export default {
startsOn: 'Kezdete:',
upcomingEvents: 'Közelgő események',
featuredGame: 'Kiemelt játék',
playInBrowser: 'Játék böngészőben',
playInBrowser: 'Játék böngészőben',
viewProjectDetails: 'Projekt részletei',
latestFromYoutube: 'Legújabb YouTube-ról',
visitChannel: 'Csatorna megtekintése ↗',
@@ -120,7 +120,7 @@ export default {
},
author: 'Szerző:',
platform: 'Platform:',
play: 'Játék',
play: 'Játék',
moreInfo: 'Részletek',
downloads: 'letöltés',
},
@@ -133,7 +133,7 @@ export default {
about: 'Leírás',
latestStable: 'Legújabb stabil',
released: 'Kiadva:',
playNow: 'Játék most',
playNow: 'Játék most',
download: 'Letöltés',
source: 'Forráskód',
docs: 'Dokumentáció',
@@ -144,7 +144,7 @@ export default {
notFound: 'A játék nem található',
failedToLoad: 'Nem sikerült betölteni:',
loading: 'Betöltés...',
play: 'Játék',
play: 'Játék',
downloads: 'letöltés',
links: 'Linkek',
platformLinks: 'Platform linkek',
@@ -198,23 +198,36 @@ export default {
title: 'Build mátrix',
subtitle: 'Melyik engine melyik platformra fordít.',
},
batocera: {
title: 'Batocera Store',
subtitle: 'A katalógusunk közvetlenül a retró gépeden.',
lead: 'A ttg-batocera-store a katalógusunkat teszi fel a Batocerára, a plug-and-play retró gamer disztribúcióra. A játékainkat a gép ROM mappáiba tölti le, borítóképpel és EmulationStation metaadatokkal együtt, és a Ports menüből bármikor újrafuttatható az új kiadásokért. Csak a Python 3 alapkönyvtárát használja — nem kell mellé semmit telepíteni.',
repo: 'Git tároló',
stores: {
title: 'Store-ok',
subtitle: 'A játékaink a saját gépeden, mindig frissen.',
viewClient: 'WarpEngine Client',
viewOthers: 'Egyéb',
clientDesc: 'Az alkalmazás: a katalógus kártyákban, egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első indításkor magát a store-t is beállítja, Windowson pedig ez az út.',
clientDownload: 'Letöltés',
clientPlatforms: 'Linux, macOS és Windows · Python 3 kell hozzá',
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
docs: 'Dokumentáció',
batoceraProject: 'Batocera projekt',
repo: 'Git repó',
installTitle: 'Telepítés',
installDesc: 'Futtasd ezt SSH-n a Batocera gépen. Telepíti a klienst, kiírja a konfigurációt, létrehozza a Ports bejegyzést és lefuttatja az első szinkront.',
installNote: 'Utána indítsd újra az EmulationStationt, hogy megjelenjenek a játékok:',
installThen: 'Utána',
useTitle: 'Használat',
useDesc: 'A gépen: Ports ▸ „Teletype Games Store”. SSH-n keresztül egy egyszerű CLI áll rendelkezésre:',
platformsTitle: 'Mit telepít',
platformsDesc: 'Egy katalógus-platform akkor telepíthető, ha a kiadás fájlja közvetlenül elindul egy Batocera rendszeren. A többi platform webes és asztali buildeket ad, ezért nincsenek leképezve.',
engineTitle: 'Csinálj saját store-t',
engineDesc: 'Itt semmi sem ránk van szabva. A mi store-unk csak egy konfigurációs fájl a warp-engine-batocera-store fölött, ami bármilyen WarpEngine alapú oldallal működik: állítsd a saját katalógusodra, adj neki nevet és saját ROM almappát, és a játékaid ugyanígy kerülnek fel egy Batocera gépre. Egy gépen több store is megfér egymás mellett anélkül, hogy egymás játékaihoz nyúlnának.',
uninstallTitle: 'Eltávolítás',
platformsTitle: 'Mi kerül fel',
copy: 'Másolás',
batocera: {
name: 'Batocera',
site: 'Batocera projekt',
tagline: 'A dugd-be-és-megy retro disztró',
},
retroarch: {
name: 'RetroArch',
site: 'RetroArch',
tagline: 'Bármi, amin fut a RetroArch',
},
},
code: {
title: 'Kódbázis',
@@ -1,201 +0,0 @@
<template>
<header class="hero-section-gradient from-emerald-700 to-teal-800 py-16">
<div class="hero-container">
<h1 class="hero-title">{{ t('batocera.title') }}</h1>
<p class="hero-subtitle text-emerald-50">{{ t('batocera.subtitle') }}</p>
</div>
</header>
<main class="main-container py-12 px-4 mt-0">
<div class="bat-panel">
<p class="bat-lead">{{ t('batocera.lead') }}</p>
<div class="bat-links">
<a :href="REPO_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('batocera.repo') }}
</a>
<a :href="WIKI_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('batocera.docs') }}
</a>
<a :href="BATOCERA_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-emerald">
<i class="fa-solid fa-tv"></i> {{ t('batocera.batoceraProject') }}
</a>
</div>
</div>
<section class="bat-section">
<h2 class="bat-section-title">
<span class="bat-step">1</span> {{ t('batocera.installTitle') }}
</h2>
<p class="bat-section-desc">{{ t('batocera.installDesc') }}</p>
<div class="bat-code">
<pre><code>{{ INSTALL_CMD }}</code></pre>
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(INSTALL_CMD)">
<i :class="copied === INSTALL_CMD ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
</button>
</div>
<p class="bat-note">{{ t('batocera.installNote') }}</p>
<div class="bat-code">
<pre><code>{{ RESTART_CMD }}</code></pre>
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(RESTART_CMD)">
<i :class="copied === RESTART_CMD ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
</button>
</div>
</section>
<section class="bat-section">
<h2 class="bat-section-title">
<span class="bat-step">2</span> {{ t('batocera.useTitle') }}
</h2>
<p class="bat-section-desc">{{ t('batocera.useDesc') }}</p>
<div class="bat-code">
<pre><code>{{ CLI_SNIPPET }}</code></pre>
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(CLI_SNIPPET)">
<i :class="copied === CLI_SNIPPET ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
</button>
</div>
</section>
<section class="bat-section">
<h2 class="bat-section-title">{{ t('batocera.platformsTitle') }}</h2>
<p class="bat-section-desc">{{ t('batocera.platformsDesc') }}</p>
<ul class="bat-platforms">
<li v-for="p in platforms" :key="p.platform" class="bat-platform">
<i :class="p.icon" class="bat-platform-icon"></i>
<span class="bat-platform-name">{{ p.label }}</span>
<code class="bat-platform-ext">{{ p.ext }}</code>
</li>
</ul>
</section>
<section class="bat-section bat-engine">
<h2 class="bat-section-title">
<i class="fa-solid fa-cubes bat-engine-icon"></i> {{ t('batocera.engineTitle') }}
</h2>
<p class="bat-section-desc">{{ t('batocera.engineDesc') }}</p>
<a :href="ENGINE_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-slate">
<i class="fa-solid fa-code-branch"></i> warp-engine-batocera-store
</a>
</section>
<div class="bat-back">
<RouterLink to="/catalog" class="bat-back-link">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
const { t } = useI18n()
const FORGE = 'https://git.teletypegames.org/tools'
const REPO_URL = `${FORGE}/ttg-batocera-store`
// The store is only a config; the client itself is a reusable engine that runs
// against any WarpEngine site, so it gets its own repository and its own link.
const ENGINE_URL = `${FORGE}/warp-engine-batocera-store`
const WIKI_URL = `${CONFIG.wikiBase}/others/ttg-batocera-store`
const BATOCERA_URL = 'https://batocera.org'
// The installer is served straight from the forge, so this one line is the
// whole install on a Batocera box.
const INSTALL_CMD = `curl -fsSL ${REPO_URL}/raw/branch/master/install.sh | bash`
const RESTART_CMD = 'batocera-es-swissknife --restart'
const STORE_CLI = '/userdata/system/batocera-store/ttg-store'
const CLI_SNIPPET = [
`${STORE_CLI} list # compatible catalog entries`,
`${STORE_CLI} sync # download everything new`,
`${STORE_CLI} remove c64demo`,
].join('\n')
const platforms = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg', icon: 'fa-solid fa-floppy-disk' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic', icon: 'fa-solid fa-tv' },
]
const copied = ref('')
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text)
copied.value = text
setTimeout(() => { if (copied.value === text) copied.value = '' }, 2000)
} catch {
// Clipboard API needs a secure context — the command stays selectable anyway.
}
}
</script>
<style scoped>
.bat-panel {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8;
}
.bat-lead {
@apply text-gray-600 leading-relaxed mb-6;
}
.bat-links {
@apply flex flex-wrap gap-3;
}
.bat-link {
@apply inline-flex items-center gap-2 font-bold py-2.5 px-5 rounded-xl text-sm text-white transition-all active:scale-95;
}
.bat-link-dark { @apply bg-slate-800 hover:bg-slate-900 shadow-lg shadow-slate-800/20; }
.bat-link-slate { @apply bg-slate-600 hover:bg-slate-700 shadow-lg shadow-slate-600/20; }
.bat-link-indigo { @apply bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-600/20; }
.bat-link-emerald { @apply bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-600/20; }
.bat-section {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8 mt-6;
}
.bat-section-title {
@apply flex items-center gap-3 text-xl font-bold text-gray-900 mb-2;
}
.bat-step {
@apply inline-flex items-center justify-center w-7 h-7 rounded-full bg-emerald-100 text-emerald-700 text-sm font-bold;
}
.bat-section-desc {
@apply text-gray-600 mb-4;
}
.bat-note {
@apply text-gray-600 mt-4 mb-3 text-sm;
}
.bat-code {
@apply relative bg-gray-900 text-gray-100 rounded-xl overflow-x-auto;
}
.bat-code pre {
@apply p-4 pr-14 font-mono text-sm leading-relaxed;
}
.bat-copy {
@apply absolute top-2 right-2 w-9 h-9 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors;
}
.bat-engine {
@apply bg-gray-50;
}
.bat-engine-icon {
@apply text-gray-400 text-lg;
}
.bat-platforms {
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
}
.bat-platform {
@apply flex items-center gap-3 bg-gray-50 border border-gray-100 rounded-xl px-4 py-3;
}
.bat-platform-icon {
@apply text-gray-400;
}
.bat-platform-name {
@apply font-medium text-gray-800 flex-grow;
}
.bat-platform-ext {
@apply font-mono text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded;
}
.bat-back {
@apply mt-8 text-center;
}
.bat-back-link {
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
}
</style>
@@ -67,8 +67,8 @@
<i class="fa-solid fa-table-cells"></i> {{ t('builds.title') }}
</RouterLink>
<span class="builds-link-sep">|</span>
<RouterLink to="/batocera" class="builds-link">
<i class="fa-solid fa-gamepad"></i> {{ t('batocera.title') }}
<RouterLink to="/stores" class="builds-link">
<i class="fa-solid fa-gamepad"></i> {{ t('stores.title') }}
</RouterLink>
</div>
</main>
@@ -0,0 +1,43 @@
<template>
<div class="cb">
<pre><code>{{ command }}</code></pre>
<button class="cb-copy" :title="t('stores.copy')" @click="copy">
<i :class="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
</button>
</div>
</template>
<script setup lang="ts">
// The page shows five of these — install, restart, CLI, uninstall — so the
// block and its copy button live in one place rather than being repeated.
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { command } = defineProps<{ command: string }>()
const { t } = useI18n()
const copied = ref(false)
async function copy() {
try {
await navigator.clipboard.writeText(command)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// Clipboard API needs a secure context — the command stays selectable anyway.
}
}
</script>
<style scoped>
.cb {
@apply relative block bg-gray-900 text-gray-100 rounded-xl overflow-x-auto;
}
/* Explicitly margin-free: a `pre` carries a browser margin of its own, and the block is
spaced by whatever contains it — otherwise what follows ends up against it. */
.cb pre {
@apply m-0 p-4 pr-14 font-mono text-sm leading-relaxed;
}
.cb-copy {
@apply absolute top-2 right-2 w-9 h-9 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors;
}
</style>
@@ -0,0 +1,343 @@
<template>
<header class="hero-section-gradient from-emerald-700 to-teal-800 py-10">
<div class="hero-container">
<h1 class="hero-title">{{ t('stores.title') }}</h1>
<p class="hero-subtitle text-emerald-50">{{ t('stores.subtitle') }}</p>
</div>
</header>
<main class="main-container py-8 px-4 mt-0">
<!-- Two ways in, and almost everybody wants the first one. A thin switcher rather
than a page of prose: whoever needs the detail follows a link to the wiki. -->
<nav class="st-views" role="tablist" :aria-label="t('stores.title')">
<button
class="st-view" :class="{ 'st-view-active': view === 'client' }"
role="tab" :aria-selected="view === 'client'" @click="selectView('client')"
>
<i class="fa-solid fa-window-maximize"></i> {{ t('stores.viewClient') }}
</button>
<button
class="st-view" :class="{ 'st-view-active': view === 'others' }"
role="tab" :aria-selected="view === 'others'" @click="selectView('others')"
>
<i class="fa-solid fa-terminal"></i> {{ t('stores.viewOthers') }}
</button>
</nav>
<section v-if="view === 'client'" class="st-section st-app">
<div class="st-app-grid">
<div>
<p class="st-app-lead">{{ t('stores.clientDesc') }}</p>
<div class="st-links">
<a
:href="client.releasesUrl" target="_blank" rel="noopener noreferrer"
class="st-link st-link-cta"
>
<i class="fa-solid fa-download"></i> {{ t('stores.clientDownload') }}
</a>
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
</a>
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
</a>
</div>
<p class="st-app-platforms">
<i class="fa-brands fa-linux"></i> <i class="fa-brands fa-apple"></i>
<i class="fa-brands fa-windows"></i> {{ t('stores.clientPlatforms') }}
</p>
</div>
<!-- Eager, with its dimensions given: it sits in the first screen, so lazy
loading it would only buy a reflow. -->
<figure class="st-app-shot">
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="2304" height="1664">
</figure>
</div>
</section>
<template v-else>
<div class="st-devices" role="tablist" :aria-label="t('stores.viewOthers')">
<button
v-for="d in devices"
:key="d.id"
class="st-device"
:class="{ 'st-device-active': d.id === device }"
role="tab"
:aria-selected="d.id === device"
@click="select(d.id)"
>
<i :class="d.icon" class="st-device-icon"></i>
<span class="st-device-name">{{ t(`stores.${d.id}.name`) }}</span>
<span class="st-device-tagline">{{ t(`stores.${d.id}.tagline`) }}</span>
</button>
</div>
<section class="st-section">
<!-- The spacing lives on this one container. Margins on the labels and none on
the button row is what let the buttons sit flush against the last block. -->
<div class="st-cmds">
<p class="st-cmd-label">{{ t('stores.installTitle') }}</p>
<CommandBlock :command="current.installCmd" />
<template v-if="current.afterInstallCmd">
<p class="st-cmd-label">{{ t('stores.installThen') }}</p>
<CommandBlock :command="current.afterInstallCmd" />
</template>
<p class="st-cmd-label">{{ t('stores.useTitle') }}</p>
<CommandBlock :command="current.cliSnippet" />
<p class="st-cmd-label">{{ t('stores.uninstallTitle') }}</p>
<CommandBlock :command="current.uninstallCmd" />
</div>
<div class="st-links st-links-after">
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
</a>
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
</a>
<a :href="current.projectUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-emerald">
<i :class="current.icon"></i> {{ t(`stores.${device}.site`) }}
</a>
</div>
</section>
<!-- Which catalog platforms a store engine can put on a device. The app has no
such list to show: it installs whatever the catalog offers, and says so on the
cards themselves. -->
<section class="st-section">
<h2 class="st-section-title">{{ t('stores.platformsTitle') }}</h2>
<ul class="st-platforms">
<li v-for="p in platforms" :key="p.platform" class="st-platform">
<i :class="p.icon" class="st-platform-icon"></i>
<span class="st-platform-name">{{ p.label }}</span>
<code class="st-platform-ext">{{ p.ext }}</code>
</li>
</ul>
</section>
</template>
<div class="st-back">
<RouterLink to="/catalog" class="st-back-link">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
import CommandBlock from './CommandBlock.vue'
import clientScreenshot from '../../assets/warpengine-client.png'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const FORGE = 'https://git.teletypegames.org/stores'
// The graphical client. Its own thing rather than a link on the desktop store: it
// drives any WarpEngine store the site's registry offers, and on Windows it is the
// only way in — there is no `curl … | sh` there.
const CLIENT = {
repoUrl: `${FORGE}/warp-engine-client`,
releasesUrl: `${FORGE}/warp-engine-client/releases`,
wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`,
}
type DeviceId = 'batocera' | 'retroarch'
// The devices below are the ones a store reaches through a shell installer. An
// ordinary computer is not among them any more: there the store *is* the app in the
// section above, which carries its own engine and needs nothing installed first.
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
const devices = [
{
id: 'batocera' as DeviceId,
icon: 'fa-solid fa-tv',
projectUrl: 'https://batocera.org',
repoUrl: `${FORGE}/ttg-batocera-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
// The installer is served straight from the forge, so this one line is the
// whole install on the device.
installCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/install.sh | sh`,
afterInstallCmd: 'batocera-es-swissknife --restart',
uninstallCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/uninstall.sh | sh`,
cliSnippet: [
`${BATOCERA_CLI} list # compatible catalog entries`,
`${BATOCERA_CLI} sync # download everything new`,
`${BATOCERA_CLI} remove c64demo`,
].join('\n'),
},
{
id: 'retroarch' as DeviceId,
icon: 'fa-solid fa-gamepad',
projectUrl: 'https://www.retroarch.com',
repoUrl: `${FORGE}/ttg-retroarch-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
installCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/install.sh | sh`,
afterInstallCmd: '',
uninstallCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/uninstall.sh | sh`,
cliSnippet: [
`${RETROARCH_CLI} paths # where things go, and whether the cores are there`,
`${RETROARCH_CLI} list # compatible catalog entries`,
`${RETROARCH_CLI} sync # download everything new`,
`${RETROARCH_CLI} remove c64demo`,
].join('\n'),
},
]
type ViewId = 'client' | 'others'
// `?view=` and `?device=` keep a link shareable, and let /batocera redirect here without
// losing which store the visitor came for. Query only: the router does not scroll for a
// query-only change, so switching does not throw the page back to the top.
const initial = devices.some((d) => d.id === route.query.device)
? (route.query.device as DeviceId)
: 'batocera'
const device = ref<DeviceId>(initial)
// The client view is what most visitors want, so it is the default — except when the URL
// names a device, which is how /batocera and /retroarch redirect here. Somebody arriving
// from those wants the integrations, not the app.
const view = ref<ViewId>(
route.query.view === 'others' || (route.query.view === undefined && route.query.device !== undefined)
? 'others'
: 'client',
)
const current = computed(() => devices.find((d) => d.id === device.value) ?? devices[0])
// The app is not tied to a device tab: it drives whichever store the site's registry
// offers, so it stands above the chooser rather than inside it.
const client = CLIENT
function select(id: DeviceId) {
device.value = id
void router.replace({ query: { ...route.query, device: id } })
}
function selectView(id: ViewId) {
view.value = id
void router.replace({ query: { ...route.query, view: id } })
}
const platforms = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg', icon: 'fa-solid fa-floppy-disk' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic', icon: 'fa-solid fa-tv' },
]
</script>
<style scoped>
.st-links {
@apply flex flex-wrap gap-3;
}
.st-link {
@apply inline-flex items-center gap-2 font-bold py-2.5 px-5 rounded-xl text-sm text-white transition-all active:scale-95;
}
.st-link-dark { @apply bg-slate-800 hover:bg-slate-900 shadow-lg shadow-slate-800/20; }
.st-link-indigo { @apply bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-600/20; }
.st-link-emerald { @apply bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-600/20; }
.st-section {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8 mt-6;
}
.st-section-title {
@apply flex items-center gap-3 text-xl font-bold text-gray-900 mb-2;
}
.st-devices {
@apply grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6;
}
.st-device {
@apply flex flex-col items-start gap-1 text-left bg-gray-50 border-2 border-gray-100 rounded-xl px-4 py-3 transition-all hover:border-emerald-200 active:scale-95;
}
.st-device-active {
@apply bg-emerald-50 border-emerald-500;
}
.st-device-icon {
@apply text-gray-400;
}
.st-device-active .st-device-icon {
@apply text-emerald-600;
}
.st-device-name {
@apply font-bold text-gray-900;
}
.st-device-tagline {
@apply text-sm text-gray-500;
}
/* The switcher is meant to be almost nothing: two words and a line under the active one. */
.st-views {
@apply flex gap-1 border-b border-gray-200 mb-6;
}
.st-view {
@apply flex items-center gap-2 px-4 py-2 -mb-px text-sm font-semibold text-gray-500 border-b-2 border-transparent transition-colors hover:text-gray-800;
}
.st-view-active {
@apply text-emerald-700 border-emerald-500;
}
/* One rhythm for the whole command list, so nothing depends on a margin someone
remembered to add. */
.st-cmds {
@apply space-y-2;
}
.st-cmd-label {
@apply text-xs font-semibold uppercase tracking-wide text-gray-400 pt-3;
}
.st-cmd-label:first-child {
@apply pt-0;
}
.st-links-after {
@apply mt-6;
}
.st-engine-link {
@apply text-indigo-600 hover:underline font-mono text-xs;
}
/* The app leads the page, so it is the one section that looks like an offer rather
than a paragraph. */
.st-app {
@apply bg-gradient-to-br from-emerald-50 to-teal-50 border-emerald-200;
}
.st-app-lead {
@apply text-gray-700 text-lg leading-relaxed mb-4;
}
.st-app-platforms {
@apply flex items-center gap-2 text-sm text-gray-500 mt-4;
}
.st-app-grid {
@apply grid gap-6 items-start lg:grid-cols-2;
}
.st-app-shot {
@apply rounded-xl overflow-hidden border border-emerald-200/70 shadow-lg bg-gray-900;
}
.st-app-shot img {
@apply block w-full h-auto;
}
.st-link-cta {
@apply bg-emerald-600 text-white hover:bg-emerald-700 text-base px-5 py-2.5 font-semibold;
}
.st-platforms {
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
}
.st-platform {
@apply flex items-center gap-3 bg-gray-50 border border-gray-100 rounded-xl px-4 py-3;
}
.st-platform-icon {
@apply text-gray-400;
}
.st-platform-name {
@apply font-medium text-gray-800 flex-grow;
}
.st-platform-ext {
@apply font-mono text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded;
}
.st-back {
@apply mt-8 text-center;
}
.st-back-link {
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
}
</style>
@@ -1,5 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
export const batoceraRouter: RouteRecordRaw[] = [
{ path: '/batocera', name: 'batoceraIndex', component: () => import('../page/batocera/BatoceraIndexPage.vue') },
]
+9 -3
View File
@@ -8,7 +8,7 @@ import { enginesRouter } from './engines.router'
import { howtosRouter } from './howtos.router'
import { teamRouter } from './team.router'
import { buildsRouter } from './builds.router'
import { batoceraRouter } from './batocera.router'
import { storesRouter } from './stores.router'
export const router = createRouter({
history: createWebHistory(),
@@ -22,9 +22,15 @@ export const router = createRouter({
...howtosRouter,
...teamRouter,
...buildsRouter,
...batoceraRouter,
...storesRouter,
],
scrollBehavior() {
scrollBehavior(to, from, savedPosition) {
// Back and forward land where the visitor was.
if (savedPosition) return savedPosition
// A query-only change is not a new page. The stores page writes its switcher into the
// URL so a link stays shareable, and throwing the reader back to the top for that
// felt like a reload.
if (to.path === from.path) return false
return { top: 0 }
},
})
+13
View File
@@ -0,0 +1,13 @@
import type { RouteRecordRaw } from 'vue-router'
export const storesRouter: RouteRecordRaw[] = [
{ path: '/stores', name: 'storesIndex', component: () => import('../page/stores/StoresIndexPage.vue') },
// The Batocera store had a page to itself until the RetroArch store joined it.
// Both device names keep working as URLs — the old links, and the guess someone
// makes after reading "RetroArch store" — and each lands on its own tab.
{ path: '/batocera', redirect: { name: 'storesIndex', query: { device: 'batocera' } } },
{ path: '/retroarch', redirect: { name: 'storesIndex', query: { device: 'retroarch' } } },
// `/desktop` used to name a shell store for ordinary computers. That store is the
// app now, so the old URL lands on the app rather than on a device tab.
{ path: '/desktop', redirect: { name: 'storesIndex' } },
]
+2 -2
View File
@@ -43,7 +43,7 @@ services:
- interstack
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3.16.0
image: woodpeckerci/woodpecker-server:v3.17.0
container_name: woodpecker-server
environment:
WOODPECKER_HOST: "https://${WOODPECKER_DOMAIN}"
@@ -71,7 +71,7 @@ services:
- interstack
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3.16.0
image: woodpeckerci/woodpecker-agent:v3.17.0
container_name: woodpecker-agent
environment:
WOODPECKER_SERVER: "woodpecker-server:9000"
+164 -6
View File
@@ -5,7 +5,7 @@ software catalog: catalog models, a CI-pipeline-callable release updater, a
public read-only JSON API, and optional ActiveAdmin resources that plug into
your app's existing admin.
Repository: `https://git.teletypegames.org/tools/warp_engine`
Repository: `https://git.teletypegames.org/engines/warp_engine`
## Features
@@ -18,6 +18,12 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
a shared secret or by per-owner database tokens with expiry and scopes
(`ApplicationToken`, managed in the admin).
- **Pluggable access**: a host supplies a policy and the catalog gains prices,
entitlements and gated downloads — and *says so* in its API, so clients can
show a paid title as paid instead of failing at the download. Default `:open`
is the catalog as it always was.
- **Client sign-in**: an RFC 8628 device authorization grant for clients with no
browser of their own, over the host's own user model. Off unless configured.
- **Pluggable storage**: artifacts are served through a storage adapter
(`:local` by default); a host can serve them from an object store without
patching the engine.
@@ -152,17 +158,22 @@ From the git repository:
```ruby
# Gemfile
gem "warp_engine", git: "https://git.teletypegames.org/tools/warp_engine.git"
gem "warp_engine", git: "https://git.teletypegames.org/engines/warp_engine.git"
```
Or from the Forgejo rubygems registry (tagged releases):
Or from the Forgejo rubygems registry, which is where **tagged releases** land — a
version rather than whatever a branch happens to hold:
```ruby
source "https://git.teletypegames.org/api/packages/tools/rubygems" do
gem "warp_engine"
source "https://git.teletypegames.org/api/packages/engines/rubygems" do
gem "warp_engine", "~> 0.5"
end
```
The registry is publicly readable, so no credential is needed to install from it. Use a
**block-scoped** source rather than a second global one: with two global sources Bundler
cannot say which gem came from where.
Then:
```sh
@@ -206,6 +217,18 @@ Rails.application.config.to_prepare do
# after backfilling owners — ownerless softwares are claimable by anyone.
# c.enforce_software_ownership = true
# Who may see a title and who may download it (see "Access" below).
# :open (default) lists everything and serves everything.
# c.access_policy = MyStore::AccessPolicy.new
# Client sign-in (see "Client sign-in" below). nil (default) means there is
# none: /api/auth/* answers 404 and GET /api/service reports auth: null.
# c.access_token_owner_class = "User"
# c.identity_verification_url = "/devices"
#
# How to recognise a caller with a session instead of a bearer token.
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
# If your app's own models reference catalog images, register them so the
# admin Images page counts them as "in use":
# c.image_owners = [
@@ -369,6 +392,106 @@ signing adapter turns them into redirects without any further change.
the admin file manager — still writes to the local disk. A remote adapter
needs its own upload path today.
## Access
Who may see a title, and who may download it. The default answers "everyone" to
both — every software listed, every artifact served, no prices — which is the
catalog the engine always had:
```ruby
c.access_policy = :open # default
```
A host that sells supplies a policy instead. The contract is three methods:
```ruby
class MyStore::AccessPolicy
# Which titles GET /api/software lists at all.
def visible_software_scope(subject: nil) = ... # an ActiveRecord scope
# What a client is told about one title.
def access_for(software:, subject: nil)
WarpEngine::Access.new(
gated: true, entitled: false, # needs an entitlement; this caller has none
price_cents: 1490, currency: "EUR",
purchase_url: "https://shop.example/games/slug",
web_url: "https://shop.example/play/slug" # nil keeps the engine's own /file/ path
)
end
# nil refuses the download; a Grant allows it.
def authorize_download(asset:, subject:, request:) = WarpEngine::Access::Grant.new
end
c.access_policy = MyStore::AccessPolicy.new
```
`subject` is whoever the request authenticated as, or `nil` for an anonymous
caller — deliberately untyped, because the engine has no user model and whose
object this is belongs to the host.
Every catalog entry carries an `access` block, **including under the open
policy**, so a client never has to tell "this catalog says nothing" from "this
title is not gated":
```json
"access": { "gated": false, "entitled": true, "price": null,
"purchaseUrl": null, "webUrl": null }
```
The vocabulary is generic on purpose. A client reads more than one store, and a
word from any one host's domain would make it specific to that host.
**A policy that raises is treated as a refusal**: an empty catalog and a denied
download, logged. An artifact served because the gatekeeper crashed is the one
failure mode this engine must not have.
## Client sign-in
A desktop client has no cookie jar and no browser session, so it cannot host a
login form without asking somebody to type a password into a window that is not
a browser. The engine implements the device authorization grant (RFC 8628)
instead — but only where a host has said whose tokens these are:
```ruby
c.access_token_owner_class = "User" # nil (default): no sign-in at all
c.identity_verification_url = "/devices" # your page where a person types the code
c.device_code_ttl = 600
c.device_code_interval = 5
```
With `access_token_owner_class` unset, `/api/auth/*` answers 404 and
`GET /api/service` reports `auth: null`, so a client offers no sign-in.
The flow:
1. the client `POST`s `/api/auth/device` and shows the `userCode` it gets back;
2. the person opens `verificationUrl` in a browser and types that code;
3. **your page** calls `WarpEngine::DeviceGrantService#approve(user_code:, subject:)`
with the signed-in user — approving needs a session and HTML, neither of
which is the engine's business;
4. the client's next `POST /api/auth/device/token` carries the token away. It is
handed over exactly once and never stored in the clear afterwards.
### Recognising a browser
A bearer token is what a *client* carries; a browser carries a session, and the
engine has no idea what a session is. A host that wants its signed-in visitors
recognised on these endpoints too — so that clicking a download link on the site
works the same way the client's download does — says how:
```ruby
c.subject_resolver = ->(request) { request.env["warden"]&.user }
```
Without one, a request with no bearer token is anonymous, which is what the
read-only API always did. A resolver that raises is logged and treated as
anonymous rather than taking the request down with it.
The token is a `WarpEngine::ApplicationToken` with the `catalog` scope, sent as
`Authorization: Bearer …`. `DELETE /api/auth/token` revokes it (signing out),
and the admin lists both kinds of token and the sign-ins behind them.
## Publish events
Publishing a release emits an `ActiveSupport::Notifications` event, so a host
@@ -387,17 +510,43 @@ end
Hosts that must support older engine versions can feature-detect with
`WarpEngine.respond_to?(:instruments_publish?) && WarpEngine.instruments_publish?`.
Downloads emit one too — `warp_engine.download`, with `path`, `asset`,
`release`, `software`, `subject` and the `Download` record — so a host can keep
its own account of who fetched what without reaching into `DownloadService`.
## Public API
| Endpoint | Purpose |
| --- | --- |
| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?owner_id=` filters to one publisher |
| `GET /api/service` | What this deployment is: version, whether the catalog gates, and how to sign in (or that you cannot) |
| `GET /api/software` | Full catalog with releases, assets, links, download counts and an `access` block; `?owner_id=` filters to one publisher |
| `GET /api/software/highlighted` | The currently highlighted title |
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
| `GET /api/image/:id` | Serves catalog images |
| `GET /api/download?path=` | Serves an artifact and logs a download record |
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
| `POST /api/auth/device` | Starts a device sign-in; returns the code pair (404 without a client identity) |
| `POST /api/auth/device/token` | Polls a device sign-in for its token |
| `DELETE /api/auth/token` | Revokes the bearer token on the request (signing out) |
Every read endpoint accepts an optional `Authorization: Bearer …`; none requires
one. What it changes is what the access policy is asked about — an anonymous
caller is a normal, supported caller.
### `WarpEngine-Version`
Every response above carries the engine's version in a `WarpEngine-Version` header, so a
client can branch on the engine's age without a round trip to ask:
```
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
WarpEngine-Version: 0.5.0
```
Set before the action runs rather than after, which means an error response carries it
too — a client needs the version most when something came back wrong. The name is
`WarpEngine::VERSION_HEADER`, so nothing spells it out twice.
## Admin integration
@@ -419,6 +568,15 @@ host:
former Go backend (Go zero-time timestamps, camelCase keys, legacy flat
path fields).
- Model extension points: `ActiveSupport.on_load(:warp_engine_<model>)` hooks.
- **A software has one pipeline, and the newest assignment wins.** `Software#pipeline` is
a `has_one`, so two pipelines pointing at the same software is not an error the database
catches — it is a link that silently does nothing, with the software still showing
whichever row came first. Assigning a software that another pipeline holds therefore
*moves* it: the previous holder is left without one, the admin says which one it took it
from, and `Pipeline#software_taken_from` carries that list for anything else that cares.
Deliberately a callback rather than a unique index: rows here are soft-deleted, and a
unique index counts deleted rows, so a pipeline removed last year would block its
software from ever being linked again.
## Tests
@@ -10,6 +10,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
scope :all, default: true
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
# Two kinds of token share this table: one publishes software, the other reads the
# catalog from somebody's desktop client. They are told apart by scope, and an admin
# looking for one is rarely looking for the other.
CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze
scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") }
scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) }
index do
id_column
@@ -45,7 +51,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
end
f.input :name
f.input :scopes_string, label: "Scopes (comma separated)",
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload.)
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload, the "catalog" scope for a client reading the API. Client tokens are normally issued by device sign-in rather than created here.)
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
f.input :expires_at, hint: "Leave empty for a token that never expires."
end
@@ -0,0 +1,62 @@
ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
# Read-only on purpose. A grant is created by a client and answered by a person on the
# host's own page; an admin creating one by hand would be issuing somebody else a
# credential, which is not a thing this page should make easy.
actions :index, :show
menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins",
if: proc { WarpEngine.identity_configured? }
config.sort_order = "created_at_desc"
config.batch_actions = false
scope :all, default: true
scope("Pending") { |scope| scope.where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
scope("Approved") { |scope| scope.where.not(approved_at: nil) }
scope("Denied") { |scope| scope.where.not(denied_at: nil) }
index do
id_column
column("Code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
column("Device") { |g| g.client_name }
column("State") { |g| status_tag g.state.to_s }
column("Who") do |g|
next "" if g.subject_id.blank?
subject = g.subject
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
end
column :expires_at
column :created_at
end
filter :client_name_cont, label: "Device"
filter :created_at
filter :expires_at
show do
attributes_table do
row("User code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
row("Device") { |g| g.client_name }
row("State") { |g| status_tag g.state.to_s }
row("Who") do |g|
next "" if g.subject_id.blank?
subject = g.subject
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
end
# The device code itself is never shown: it is the client's live credential for as
# long as the grant is pending, and this page is not where it should leak from.
row("Token") do |g|
token = g.application_token
next "" if token.nil?
link_to "#{token.token_prefix}… (#{token.name})", admin_application_token_path(token)
end
row :approved_at
row :denied_at
row :expires_at
row :created_at
end
end
end
+9 -1
View File
@@ -1,6 +1,11 @@
ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
actions :index, :show, :edit, :update
# Without this the edit form cannot save at all: ActiveAdmin hands unpermitted params to
# the model and Rails raises ForbiddenAttributesError. The two fields here are the two
# the form offers; everything else about a pipeline comes from the Woodpecker sync.
permit_params :platform, :software_id
menu parent: "🌀 WarpEngine", priority: 10, label: "🚀 Pipelines"
config.sort_order = "repo_name_asc"
@@ -46,7 +51,10 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
f.input :software_id, as: :select,
collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] },
include_blank: "- none -"
include_blank: "- none -",
hint: "One pipeline per software. Picking one that another pipeline already " \
"has moves the link here — that pipeline is left without a software, " \
"and the move is written to the log."
end
f.actions
end
@@ -0,0 +1,74 @@
module WarpEngine
# Who the caller is, on the read-only side of the API.
#
# Distinct from UpdateAuthentication, which guards publishing: that one asks "may this
# pipeline write to the catalog", this one asks "whose library am I looking at". The
# answer is allowed to be nobody — an anonymous caller is a normal, supported caller,
# and a catalog with no policy configured never needs one.
#
# The credential is a bearer token, because that is what a client can carry: it has no
# cookie jar and no browser session.
module SubjectAuthentication
extend ActiveSupport::Concern
private
# The ApplicationToken behind the request, or nil.
def current_access_token
return @current_access_token if defined?(@current_access_token)
@current_access_token = resolve_access_token
end
# Whoever the request is on behalf of — the host's own object, or nil.
#
# Two ways to be somebody, tried in that order. A bearer token is what a client
# carries. A *browser* carries a session instead, and the engine has no idea what a
# session is here — so a host that wants its signed-in visitors recognised on these
# endpoints supplies a resolver:
#
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
#
# Without one, a browser is simply anonymous, which is what it always was.
def current_subject
return @current_subject if defined?(@current_subject)
@current_subject = current_access_token&.owner || resolve_host_subject
end
def resolve_access_token
return nil unless WarpEngine.identity_configured?
token = bearer_token
return nil if token.blank?
record = WarpEngine::ApplicationToken.authenticate(
token, required_scope: WarpEngine::ApplicationToken::CATALOG_SCOPE
)
return nil if record.nil?
record.touch_last_used!
record
end
# A resolver that raises must not take the request with it: it runs on every read
# endpoint, and a broken one would turn the whole API into 500s rather than into
# anonymous requests, which is the honest fallback.
def resolve_host_subject
resolver = WarpEngine.config.subject_resolver
return nil if resolver.nil?
resolver.call(request)
rescue StandardError => e
Rails.logger.error("[WarpEngine::SubjectAuthentication] subject_resolver #{e.class}: #{e.message}")
nil
end
def bearer_token
header = request.headers["Authorization"].to_s
return nil unless header.start_with?("Bearer ")
header.delete_prefix("Bearer ").strip.presence
end
end
end
@@ -0,0 +1,61 @@
module WarpEngine
# The device authorization grant, client side (RFC 8628).
#
# Both actions are unauthenticated, and have to be: the whole point of the flow is
# that the caller has no credential yet. What protects it is that a device code is
# useless until a signed-in person approves it on the host's own page.
class Api::Auth::DevicesController < ApiController
before_action :ensure_identity_configured
resource_description do
short "Device sign-in"
end
api :POST, "/api/auth/device", "Start a device sign-in and get a code pair"
param :client_name, String, required: false, desc: "What to call this device in the person's account"
returns code: 200, desc: "The code pair and where to take it"
error code: 404, desc: "This deployment has no client sign-in"
def create
grant = service.request(client_name: params[:client_name])
render json: {
deviceCode: grant.device_code,
userCode: grant.formatted_user_code,
verificationUrl: service.verification_url(base_url: request.base_url),
interval: WarpEngine.config.device_code_interval.to_i,
expiresIn: (grant.expires_at - Time.current).to_i
}
end
api :POST, "/api/auth/device/token", "Poll a device sign-in for its token"
param :device_code, String, required: true, desc: "The device code from POST /api/auth/device"
returns code: 200, desc: "state is one of pending, approved, denied, expired"
error code: 404, desc: "No such device code, or no client sign-in here"
def token
state, plain = service.poll(device_code: params[:device_code])
# The token rides on the one poll that finds the grant newly approved; a client
# that loses it starts the flow again. Keeping a plain token around to hand out
# twice would mean storing it, which is the thing this design avoids.
body = { state: state.to_s }
body[:token] = plain if plain.present?
render json: body
rescue WarpEngine::DeviceGrantService::UnknownCode
render json: { error: "Not found" }, status: :not_found
end
private
def service
@service ||= WarpEngine::DeviceGrantService.new
end
# A deployment with no configured subject class has no sign-in at all, and says so
# the same way GET /api/service does — by not offering it.
def ensure_identity_configured
return if WarpEngine.identity_configured?
render json: { error: "Not found" }, status: :not_found
end
end
end
@@ -0,0 +1,24 @@
module WarpEngine
# Signing out: a client throws away its own token.
#
# Revocation is a soft delete on the ApplicationToken, so the record of which device
# signed in and when survives it. The person's own list of devices — where somebody
# revokes a token for a laptop they no longer have — is the host's page, because it
# needs a session and a browser.
class Api::Auth::TokensController < ApiController
resource_description do
short "Client tokens"
end
api :DELETE, "/api/auth/token", "Revoke the bearer token this request carries"
returns code: 204, desc: "Revoked"
error code: 401, desc: "No usable bearer token on the request"
def destroy
token = current_access_token
return head(:unauthorized) if token.nil?
WarpEngine::DeviceGrantService.new.revoke(token: token)
head :no_content
end
end
end
@@ -10,6 +10,7 @@ module WarpEngine
returns code: 200, desc: "File binary data"
returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)"
error code: 400, desc: "Path is blank"
error code: 403, desc: "The access policy refused this caller"
error code: 404, desc: "File not found"
def show
path = params[:path]
@@ -19,7 +20,9 @@ module WarpEngine
path: path,
ip: request.remote_ip,
user_agent: request.user_agent,
referer: request.referer
referer: request.referer,
subject: current_subject,
request: request
)
if location.nil?
@@ -0,0 +1,62 @@
module WarpEngine
# What this deployment is and what it can do, in one unauthenticated request.
#
# This is how a client stops guessing. Before it existed, everything a client knew
# about a store was compiled into the client — which endpoints to call, whether
# signing in was a thing here, where to send somebody who wanted to buy something.
# Every one of those is a property of the *server*, and a client that carries them
# can only ever serve the one store it was built for.
class Api::ServiceController < ApiController
resource_description do
short "Service descriptor"
end
api :GET, "/api/service", "What this WarpEngine deployment offers"
desc <<~DESC
Public on purpose: a client reads this *before* it can have a credential.
`auth` is null where the host has configured no client identity the catalog is
open, there is nobody to sign in as, and a client should not offer to. Where it is
present, `auth.device` describes the device authorization grant a client with no
browser of its own uses to sign in.
`catalog.gated` says whether any title here can require an entitlement. A client
can render a store that never gates differently from one that sometimes does,
without having to read the whole catalog first to find out.
DESC
returns code: 200, desc: "The descriptor" do
property :engine, String, desc: "Always 'warp_engine'"
property :version, String, desc: "Engine version, same value as the WarpEngine-Version header"
property :catalog, Hash, desc: "Catalog properties" do
property :gated, :boolean, desc: "Whether titles here can require an entitlement"
end
property :auth, Hash, desc: "How to sign in, or null where there is no sign-in"
end
def show
render json: {
engine: "warp_engine",
version: WarpEngine::VERSION,
catalog: { gated: !WarpEngine::AccessPolicy.open? },
auth: auth_descriptor
}
end
private
def auth_descriptor
return nil unless WarpEngine.identity_configured?
service = WarpEngine::DeviceGrantService.new
{
schemes: [ "bearer" ],
device: {
authorizeUrl: warp_engine.api_auth_device_url,
tokenUrl: warp_engine.api_auth_device_token_url,
revokeUrl: warp_engine.api_auth_token_url,
verificationUrl: service.verification_url(base_url: request.base_url),
interval: WarpEngine.config.device_code_interval.to_i
}
}
end
end
end
@@ -56,7 +56,7 @@ module WarpEngine
end
end
def index
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id], subject: current_subject)
end
end
end
@@ -34,7 +34,7 @@ module WarpEngine
end
error code: 404, desc: "No highlighted software found"
def index
result = WarpEngine::SoftwareHighlightedService.new.index
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
if result
render json: result
else
@@ -5,6 +5,16 @@ module WarpEngine
formats [ "json" ]
end
# Every response the engine serves names the version that served it, so a client can
# branch on the engine's age without a round trip to ask. Set *before* the action,
# not after: an error handled by `rescue_from` never reaches an after_action, and a
# client needs the version most when something came back wrong.
before_action :set_version_header
# Every read-only endpoint may be called with a bearer token; none of them requires
# one. See WarpEngine::SubjectAuthentication.
include WarpEngine::SubjectAuthentication
rescue_from StandardError do |e|
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
render json: { error: "Internal server error" }, status: :internal_server_error
@@ -22,8 +32,22 @@ module WarpEngine
render json: { error: e.message }, status: :bad_request
end
rescue_from WarpEngine::DownloadService::Denied do
render json: { error: "Forbidden" }, status: :forbidden
end
private
def set_version_header
# The name is spelled out here rather than taken from WarpEngine::VERSION_HEADER on
# purpose. A deployed process can end up with these controllers and an older
# `lib/` — it happened on the first deploy of this feature — and a controller that
# needs a constant from the newer half answers 500 to every request instead of
# serving the catalog. A response header is not worth that fragility. The constant
# is still the documented name, and a spec holds the two together.
response.headers["WarpEngine-Version"] = WarpEngine::VERSION
end
def resolve_mime(path)
ext = File.extname(path.to_s).delete_prefix(".")
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
@@ -9,9 +9,12 @@ module WarpEngine
param :path, String, required: true, desc: "File path"
returns code: 200, desc: "File binary data"
returns code: 301, desc: "Redirect to file URL"
error code: 403, desc: "The access policy refused this caller"
error code: 404, desc: "File not found"
def show
result = WarpEngine::FileService.new.show(WarpEngine::FileShowInputDto.new(path: params[:path]))
result = WarpEngine::FileService.new.show(
WarpEngine::FileShowInputDto.new(path: params[:path]), subject: current_subject
)
case result.type
when :redirect then redirect_to result.url, status: :moved_permanently
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
@@ -6,6 +6,9 @@ module WarpEngine
UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze
# A token held by a *client* rather than a publisher: it reads the catalog and
# downloads artifacts, and it never publishes anything.
CATALOG_SCOPE = "catalog".freeze
# The generated token is only available in memory at creation time — the DB
# stores nothing but the SHA256 digest and the non-secret prefix.
@@ -78,6 +81,15 @@ module WarpEngine
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
end
# Publishing tokens and client tokens share this table but not their owners: one
# belongs to whoever ships software, the other to whoever buys it. Both classes are
# the host's to name, and either is acceptable here — which of the two a given token
# may do is decided by its scopes, not by its owner.
def self.permitted_owner_types
[ WarpEngine.config.application_token_owner_class,
WarpEngine.config.access_token_owner_class ].compact_blank
end
def generate_token
return if token_digest.present?
@@ -87,11 +99,11 @@ module WarpEngine
end
def owner_type_matches_configuration
expected = WarpEngine.config.application_token_owner_class
if expected.blank?
permitted = self.class.permitted_owner_types
if permitted.empty?
errors.add(:base, "application_token_owner_class is not configured")
elsif owner_type != expected
errors.add(:owner_type, "must be #{expected}")
elsif !permitted.include?(owner_type)
errors.add(:owner_type, "must be #{permitted.join(' or ')}")
end
end
@@ -0,0 +1,97 @@
module WarpEngine
# One pending sign-in from a client that has no browser of its own.
#
# The shape is RFC 8628's device authorization grant, and the reason for it is that a
# desktop client cannot host a login form without asking a person to type a password
# into a window that is not a browser. So the client asks for a pair of codes, sends
# the person to the host's own page with the short one, and polls with the long one
# until somebody approves it.
#
# Short-lived by design: this row exists for the minute or two between "the client
# asked" and "the person answered". What survives it is the ApplicationToken.
class DeviceGrant < ApplicationRecord
self.table_name = "device_grants"
# No I, O, 0 or 1: this alphabet is read off one screen and typed into another, and
# those four are where that goes wrong.
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze
USER_CODE_LENGTH = 8
belongs_to :application_token, class_name: "WarpEngine::ApplicationToken", optional: true
belongs_to :subject, polymorphic: true, optional: true
validates :device_code, presence: true, uniqueness: true
validates :user_code, presence: true, uniqueness: true
validates :expires_at, presence: true
scope :pending, -> { where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
before_validation :generate_codes, on: :create
before_validation :set_expiry, on: :create
def self.find_pending_by_user_code(code)
pending.find_by(user_code: normalize_user_code(code))
end
# Typed by a person, so it arrives with whatever case and separators they used.
def self.normalize_user_code(code)
code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
end
def expired? = expires_at <= Time.current
def approved? = approved_at.present?
def denied? = denied_at.present?
# What the polling client is told. Order matters: a denied grant is denied even
# after it expires, because "somebody said no" is the more useful answer.
def state
return :denied if denied?
return :approved if approved?
return :expired if expired?
:pending
end
# Grouped for reading aloud and for typing: WARP-K7M2.
def formatted_user_code
user_code.to_s.scan(/.{1,4}/).join("-")
end
# Housekeeping for a host that wants it: an expired grant has nothing left to give,
# and its issued_token would be a live secret nobody is waiting for.
def self.sweep_expired!
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
end
def self.ransackable_attributes(auth_object = nil)
%w[approved_at client_name created_at denied_at expires_at id subject_id subject_type updated_at user_code]
end
def self.ransackable_associations(auth_object = nil)
[]
end
private
def generate_codes
self.device_code = SecureRandom.hex(32) if device_code.blank?
self.user_code = self.class.generate_user_code if user_code.blank?
end
def self.generate_user_code
# Retried rather than trusted: the alphabet is small enough that a collision is
# a real, if rare, event, and a unique index would turn it into a 500.
10.times do
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
return candidate unless exists?(user_code: candidate)
end
raise "could not generate a free device user code"
end
def set_expiry
self.expires_at ||= WarpEngine.config.device_code_ttl.to_i.seconds.from_now
end
ActiveSupport.run_load_hooks(:warp_engine_device_grant, self)
end
end
@@ -8,8 +8,25 @@ module WarpEngine
belongs_to :software, class_name: "WarpEngine::Software", optional: true
# Pipelines this record took the software from during the last save, by full name.
# The admin says so out loud: a silent reassignment is what made the old behaviour
# confusing in the first place.
attr_reader :software_taken_from
default_scope { where(deleted_at: nil) }
# One pipeline per software, and the newest assignment wins.
#
# `Software#pipeline` is a `has_one`, so two pipelines pointing at the same software
# is not an error — it is worse than one: the software keeps showing whichever row
# comes first, and assigning it elsewhere looks like it did nothing. Rather than
# refusing the assignment, the link moves: whoever held that software lets go of it.
#
# Deliberately a callback and not a unique index. Rows here are soft-deleted, and a
# unique index counts deleted rows too, so a pipeline someone removed last year would
# block the software from ever being linked again.
before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id?
validates :woodpecker_repo_id, presence: true, uniqueness: true
validates :repo_owner, presence: true
validates :repo_name, presence: true
@@ -31,6 +48,26 @@ module WarpEngine
%w[software]
end
private
def claim_software_from_other_pipelines
return if software_id.blank?
others = Pipeline.where(software_id: software_id).where.not(id: id)
@software_taken_from = others.map(&:full_name)
return if @software_taken_from.empty?
# Logged rather than flashed. The first attempt at this put a message on screen by
# overriding the admin's `update` action, which bypassed the permitted-params path
# and made every pipeline edit fail with ForbiddenAttributesError. A silent
# reassignment is a small problem; an admin page that cannot save is a large one.
Rails.logger.info(
"[WarpEngine::Pipeline] #{full_name} took software #{software_id} from " \
"#{@software_taken_from.join(', ')}"
)
others.update_all(software_id: nil, updated_at: Time.current)
end
ActiveSupport.run_load_hooks(:warp_engine_pipeline, self)
end
end
@@ -5,5 +5,8 @@ module WarpEngine
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
# Whether this title is gated, what it costs and where to get it. Always present —
# see WarpEngine::Access. Under the open policy it is the constant OPEN answer.
field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json }
end
end
@@ -0,0 +1,102 @@
module WarpEngine
# The device authorization grant, from both ends.
#
# The client's end is #request, #poll and #revoke, all reachable over /api/auth/*.
# The person's end is #approve and #deny, which the *host* calls from its own page —
# approving needs a session and a logged-in human, and the engine has neither.
class DeviceGrantService
class NotConfigured < StandardError; end
class UnknownCode < StandardError; end
# A client asks for a code pair. Deliberately unauthenticated: there is nobody to
# authenticate as yet, which is the whole reason this flow exists.
def request(client_name:)
ensure_configured!
WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128))
end
# The client polls with the device code. Returns [state, token], where the token is
# the plain string and is available exactly once — on the poll that finds the grant
# newly approved. A second poll gets :approved with no token, which is the honest
# answer: the secret was handed over and is not kept.
def poll(device_code:)
ensure_configured!
grant = WarpEngine::DeviceGrant.find_by(device_code: device_code.to_s)
raise UnknownCode if grant.nil?
return [ grant.state, nil ] unless grant.state == :approved
# Read once, then gone: the column exists only to carry the secret across the gap
# between the browser that approved it and the client that is polling for it.
plain = grant.issued_token
grant.update_columns(issued_token: nil) if plain.present?
[ :approved, plain ]
end
# The host's approval page calls this with the code a person typed and the subject
# they are signed in as. Issuing the token here rather than on the next poll keeps
# the decision and its consequence in one transaction.
def approve(user_code:, subject:)
ensure_configured!
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
raise UnknownCode if grant.nil?
ActiveRecord::Base.transaction do
token = WarpEngine::ApplicationToken.create!(
name: grant.client_name.presence || "Client",
owner_type: WarpEngine.config.access_token_owner_class,
owner_id: subject.id,
scopes: [ WarpEngine::ApplicationToken::CATALOG_SCOPE ]
)
grant.update!(
subject_type: WarpEngine.config.access_token_owner_class,
subject_id: subject.id,
application_token: token,
issued_token: token.plain_token,
approved_at: Time.current
)
end
grant
end
def deny(user_code:)
ensure_configured!
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
raise UnknownCode if grant.nil?
grant.update!(denied_at: Time.current)
grant
end
# Signing out: the client throws its own token away. Revocation is a soft delete on
# the token, so the audit trail of who signed in from where survives it.
def revoke(token:)
return false if token.nil?
token.revoke!
true
end
# Where a person goes to type the user code. A path is made absolute against the
# request's own base, so a host that configured "/devices" does not have to know its
# own hostname.
def verification_url(base_url: nil)
configured = WarpEngine.config.identity_verification_url.presence || "/devices"
return configured if configured.start_with?("http://", "https://")
return configured if base_url.blank?
"#{base_url.to_s.chomp('/')}/#{configured.delete_prefix('/')}"
end
private
def ensure_configured!
raise NotConfigured unless WarpEngine.identity_configured?
end
end
end
@@ -1,5 +1,10 @@
module WarpEngine
class DownloadService
# The access policy said no. The controller turns this into a 403 — distinct from
# the nil that means "no such file", because telling a person their file is missing
# when it is merely locked sends them looking for the wrong problem.
class Denied < StandardError; end
def self.container_base
WarpEngine.config.file_container_path
end
@@ -12,19 +17,28 @@ module WarpEngine
# A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a
# letöltést. A hely feloldása a storage adapteren megy — alapból :local,
# tehát változatlanul lemezről.
def locate(path:, ip:, user_agent:, referer:)
#
# `subject` is whoever the request authenticated as, or nil. Under the open policy
# it is ignored and every file is served, exactly as before.
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
relative = path.to_s
return nil unless storage.file?(relative)
log_download(relative, ip: ip, user_agent: user_agent, referer: referer)
asset = find_asset(relative)
grant = authorize!(asset, subject, request)
storage.locate(relative, filename: File.basename(relative))
log_download(relative, asset: asset, ip: ip, user_agent: user_agent, referer: referer, subject: subject)
storage.locate(relative,
filename: grant.filename.presence || File.basename(relative),
expires_in: grant.expires_in)
end
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
def create(path:, ip:, user_agent:, referer:)
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer)
def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
subject: subject, request: request)
return nil if location.nil?
location.file? ? location.path : nil
@@ -36,18 +50,41 @@ module WarpEngine
WarpEngine.storage
end
def log_download(relative, ip:, user_agent:, referer:)
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
# A policy that refuses returns nil; one that raises is treated as a refusal too.
# An artifact served because the gatekeeper crashed is the one failure mode this
# engine must not have.
def authorize!(asset, subject, request)
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
raise Denied if grant.nil?
WarpEngine::Download.create!(
grant
rescue Denied
raise
rescue StandardError => e
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
raise Denied
end
def find_asset(relative)
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
end
def log_download(relative, asset:, ip:, user_agent:, referer:, subject:)
download = WarpEngine::Download.create!(
file_path: relative,
release: asset&.release,
ip_address: ip,
user_agent: user_agent&.truncate(500),
referer: referer&.truncate(500)
)
# The same seam the publish side has: a host that wants its own record of who
# downloaded what subscribes rather than reaching into this class.
ActiveSupport::Notifications.instrument("warp_engine.download",
path: relative, asset: asset, release: asset&.release,
software: asset&.release&.software, subject: subject, download: download, ip: ip)
end
end
end
@@ -8,7 +8,16 @@ module WarpEngine
# A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) —
# így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t
# patchelné. Lásd WarpEngine::Storage.
def show(input)
# `subject` is whoever the request authenticated as, or nil. Under the open policy
# it is ignored and every file is served, exactly as before.
#
# A hosted (browser) build reaches this path as hundreds of relative requests for
# js, wasm and images, which is why the gate here is the policy's plain yes/no
# rather than anything signed: there is nothing to sign per file. A host serving
# gated web builds to browsers will usually want its own session-based route in
# front of this one — a browser has a session, and a redirect to a login page is a
# better answer there than a bare 403.
def show(input, subject: nil)
relative = input.path.to_s
if storage.directory?(relative)
@@ -20,11 +29,34 @@ module WarpEngine
return FileResultDto.not_found unless storage.file?(relative)
authorize!(relative, subject)
to_result(storage.locate(relative, filename: File.basename(relative)))
end
private
# Same rule and same failure mode as DownloadService: a policy that refuses or
# raises means no file. An artifact served because the gatekeeper crashed is the
# one failure mode this engine must not have.
def authorize!(relative, subject)
# The open policy authorises everything, and this path serves a browser build as
# hundreds of requests for js, wasm and images. Asking it per file would mean a
# LIKE query per asset for an answer that is always yes.
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: nil)
raise WarpEngine::DownloadService::Denied if grant.nil?
grant
rescue WarpEngine::DownloadService::Denied
raise
rescue StandardError => e
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
raise WarpEngine::DownloadService::Denied
end
def storage
WarpEngine.storage
end
@@ -22,19 +22,42 @@ steps:
commands:
- |
VERSION=$(cat .version)
echo "==> Checking JS syntax"
for f in src/*.js; do [ -e "$f" ] || continue; node --check "$f"; done
mkdir -p dist/web
echo "==> Downloading Phaser 3.90.0"
curl -sSL https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js -o dist/web/phaser.min.js
echo "==> Downloading index.html"
curl -sSL https://git.teletypegames.org/tools/phaser-tools/raw/branch/master/web/index.html -o dist/web/index.html
echo "==> Bundling game sources"
cat src/*.js > dist/web/game.js
echo "==> Packaging web build for $VERSION"
(cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .)
echo "==> Cleaning temporary files"
rm -rf dist/web
# Ketfele projektforma el egymas mellett: a sima JS (a forrasok
# osszefuzve, a Phaser CDN-rol) es a bundleres (Vite + TypeScript),
# ami maga allitja elo a kesz webes csomagot.
if [ -f package.json ] && grep -q '"build"' package.json; then
echo "==> Bundled project — npm ci && npm run build"
npm ci
npm run build
# A Vite kimenete onmagaban teljes: index.html + a beforgatott
# assetek. A vite.config base-enek relativnak kell lennie, mert a
# jatek a /file/<nev>-<verzio>/ alkonyvtarbol szolgal ki.
if [ ! -f dist/index.html ]; then
echo "ERROR: a build nem hagyott dist/index.html-t" >&2
exit 1
fi
echo "==> Packaging web build for $VERSION"
(cd dist && zip -r "../<%= name %>-$VERSION.html.zip" .)
echo "==> Cleaning temporary files"
rm -rf dist
else
echo "==> Checking JS syntax"
# A bundleres projektben nincs src/*.js, es a shell ilyenkor a
# mintat adja tovabb literalkent — a node MODULE_NOT_FOUND-dal
# szall el rajta.
for f in src/*.js; do [ -e "$f" ] || continue; node --check "$f"; done
mkdir -p dist/web
echo "==> Downloading Phaser 3.90.0"
curl -sSL https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js -o dist/web/phaser.min.js
echo "==> Downloading index.html"
curl -sSL https://git.teletypegames.org/build/phaser-tools/raw/branch/master/web/index.html -o dist/web/index.html
echo "==> Bundling game sources"
cat src/*.js > dist/web/game.js
echo "==> Packaging web build for $VERSION"
(cd dist/web && zip -r "../../<%= name %>-$VERSION.html.zip" .)
echo "==> Cleaning temporary files"
rm -rf dist/web
fi
- name: upload
image: alpine
@@ -2,15 +2,15 @@ module WarpEngine
class SoftwareHighlightedService
include SoftwareResponseBuilder
def index
software = WarpEngine::Software.includes(:external_links, :software_images)
def index(subject: nil)
software = visible_scope(subject).includes(:external_links, :software_images)
.where(highlighted: true)
.order(id: :desc)
.first
return nil unless software
releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a
build_response(software, releases, download_counts_for(releases.map(&:id)))
build_response(software, releases, download_counts_for(releases.map(&:id)), subject: subject)
end
end
end
@@ -2,7 +2,7 @@ module WarpEngine
module SoftwareResponseBuilder
private
def build_response(software, releases, download_counts)
def build_response(software, releases, download_counts, subject: nil)
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
@@ -14,10 +14,33 @@ module WarpEngine
latest: latest,
web_playable: web_playable,
total_downloads: total_downloads,
download_counts: download_counts
download_counts: download_counts,
access: access_for(software, subject)
)
end
# Always present, even under the open policy: a client should never have to tell
# "this catalog says nothing about access" from "this title is not gated". One of
# those is a question and the other is an answer.
def access_for(software, subject)
WarpEngine.access_policy.access_for(software: software, subject: subject)
rescue StandardError => e
# A policy that raises must not take the catalog down with it. The open answer is
# wrong here, so the closed one is what a broken policy gets: a title nobody can
# download is recoverable, a paid title handed out for free is not.
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
WarpEngine::Access.new(gated: true, entitled: false)
end
# The titles this subject may see. A policy that raises empties the catalog rather
# than leaking it — the safe direction — but it is still a bug, so it is logged.
def visible_scope(subject)
WarpEngine.access_policy.visible_software_scope(subject: subject)
rescue StandardError => e
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
WarpEngine::Software.none
end
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
def download_counts_for(release_ids)
return {} if release_ids.empty?
@@ -2,11 +2,16 @@ module WarpEngine
class SoftwareService
include SoftwareResponseBuilder
def index(owner_id: nil)
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
# `subject` is whoever the request authenticated as, or nil. It decides two things:
# which titles are listed at all (the policy's scope) and what each one's `access`
# block says about entitlement.
def index(owner_id: nil, subject: nil)
softwares = visible_scope(subject)
.includes(releases: [ :release_assets ])
.includes(:external_links, :software_images)
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts, subject: subject) } }
end
end
end
+12
View File
@@ -1,5 +1,17 @@
WarpEngine::Engine.routes.draw do
namespace :api do
# What this deployment is and what it can do. A client reads it before it can have
# a credential, so it is public and cheap.
get "service", to: "service#show"
# Device sign-in, for clients that have no browser of their own (RFC 8628).
# Inactive — 404 on every action — unless the host configured a subject class.
namespace :auth do
post "device", to: "devices#create"
post "device/token", to: "devices#token"
delete "token", to: "tokens#destroy"
end
get "software", to: "software#index"
get "software/highlighted", to: "software_highlighted#index"
get "image/:id", to: "images#show"
@@ -0,0 +1,35 @@
class CreateDeviceGrants < ActiveRecord::Migration[8.1]
def change
create_table :device_grants, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
# Both codes are secrets in the sense that guessing one grants a session, but they
# have different jobs: the device code is long and never shown to a person, the
# user code is short enough to read off a screen and type into a browser.
t.string :device_code, limit: 64, null: false
t.string :user_code, limit: 16, null: false
# What the client called itself. Shown on the approval page, so a person can tell
# which machine is asking, and kept on the issued token as its name.
t.string :client_name, limit: 128
# The approving subject comes from the host
# (WarpEngine.config.access_token_owner_class), so no FK — same reasoning as
# application_tokens.owner_type.
t.string :subject_type, limit: 128
t.bigint :subject_id, unsigned: true
t.bigint :application_token_id, unsigned: true
# The issued token, in the clear, for the seconds between "approved" and "the
# client's next poll". It is cleared on the poll that hands it over, so this
# column holds a live secret only while somebody is waiting for it. There is no
# way around storing it: the approval happens in a browser and the poll arrives on
# a different request, so the two cannot share memory. Everything else about a
# token is stored as a digest — this is the one exception, and it is temporary.
t.string :issued_token, limit: 64
t.datetime :approved_at, precision: 3
t.datetime :denied_at, precision: 3
t.datetime :expires_at, precision: 3, null: false
t.timestamps precision: 3, null: true
t.index :device_code, name: "idx_device_grants_device_code", unique: true
t.index :user_code, name: "idx_device_grants_user_code", unique: true
t.index :expires_at, name: "idx_device_grants_expires_at"
end
end
end
@@ -98,6 +98,12 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.index :deleted_at
end
# NOTE: device_grants is NOT created here. It ships as its own migration in the
# engine's db/migrate, which the host runs from the appended path — so creating it
# here as well would be a second CREATE TABLE for the same name. The same is true
# of application_tokens below, which predates this note and is why a host
# installing today has to delete that block from its copy by hand.
create_table :downloads do |t|
t.string :file_path, null: false
t.references :release, foreign_key: { on_delete: :nullify }, index: false
@@ -43,6 +43,31 @@ Rails.application.config.to_prepare do
# softwares got an owner (backfill)!
# c.enforce_software_ownership = true
# Who may see a title and who may download it. :open (the default) lists every
# software and serves every artifact — the behaviour of a catalog nobody sells
# from. A host that does sell supplies a policy answering three methods; see
# WarpEngine::AccessPolicy. What it returns is what clients are told, so a paid
# title can announce itself as paid instead of failing at the download.
# c.access_policy = MyStore::AccessPolicy.new
# Client sign-in. nil (default) means there is none: /api/auth/* is inactive and
# GET /api/service reports auth: null, so a client offers no sign-in at all. Set
# the class a *client* token belongs to — usually your user model — to turn on
# the device authorization grant.
# c.access_token_owner_class = "User"
#
# Your own page where a signed-in person types the code their client displayed.
# It calls WarpEngine::DeviceGrantService#approve. A path is made absolute
# against the request, so you need not know your own hostname (default "/devices").
# c.identity_verification_url = "/devices"
# A browser has a session rather than a bearer token, and the engine cannot
# read one. Say how, and your signed-in visitors are recognised on the
# read-only endpoints too:
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
# c.device_code_ttl = 600 # seconds a pending code lives
# c.device_code_interval = 5 # seconds a client is told to wait between polls
# If host models also reference catalog images, register them so the
# admin Images page's orphan detection takes them into account:
# c.image_owners = [
+12
View File
@@ -10,6 +10,7 @@ require "apipie-rails"
require "warp_engine/version"
require "warp_engine/configuration"
require "warp_engine/storage"
require "warp_engine/access"
module WarpEngine
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
@@ -42,6 +43,17 @@ module WarpEngine
def self.storage
Storage.adapter
end
# Who may see and download what. :open by default — see WarpEngine::AccessPolicy.
def self.access_policy
AccessPolicy.current
end
# The host has configured a subject class, so client sign-in is available. A client
# asks GET /api/service rather than this, but the engine's own controllers need it.
def self.identity_configured?
config.access_token_owner_class.present?
end
end
require "warp_engine/engine"
@@ -0,0 +1,119 @@
module WarpEngine
# Who may see a title, and who may download it.
#
# Until now every catalog entry was public and every artifact was free: the API
# listed all software and /api/download handed over any file it could find. That is
# the right default for a catalog nobody sells from, and it stays the default — but a
# host that does sell needs the engine to *say so*, because the clients reading this
# API have no other way to learn it. A desktop client cannot know that a title is
# paid; it can only be told.
#
# This module is that seam. The default policy is byte for byte the previous
# behaviour, and a host swaps it on the configuration:
#
# c.access_policy = MyStorePolicy.new # or :open (default)
#
# A policy is any object answering to this contract:
#
# visible_software_scope(subject:) -> ActiveRecord::Relation
# access_for(software:, subject:) -> WarpEngine::Access
# authorize_download(asset:, subject:, request:) -> Access::Grant or nil
#
# `subject` is whoever the request authenticated as (see SubjectAuthentication), or
# nil for an anonymous caller. It is deliberately untyped here: the engine has no user
# model, and whose object this is belongs to the host.
module AccessPolicy
# Everything visible, everything open, no prices. The catalog as it always was.
class Open
def visible_software_scope(subject: nil)
WarpEngine::Software.all
end
def access_for(software:, subject: nil)
Access::OPEN
end
# An open catalog authorises every asset it can find. Returning a bare grant
# rather than `true` keeps one return type across policies.
def authorize_download(asset: nil, subject: nil, request: nil)
Access::Grant::OPEN
end
end
class << self
def current
configured = WarpEngine.config.access_policy
case configured
when nil, :open, "open" then open_policy
else configured
end
end
def open? = current.is_a?(Open)
def open_policy
@open_policy ||= Open.new
end
# Tests and hosts that swap the configuration at runtime.
def reset!
@open_policy = nil
end
end
end
# What a client is told about one title's availability.
#
# The vocabulary is deliberately generic — `gated`, `entitled`, `price` — because
# every client reading it serves more than one store. A word from any particular
# host's domain ("product", "purchase order", "library") would make the client
# that reads it specific to that host, which is exactly what this engine exists
# to prevent.
class Access
# Where a hosted (browser) build is played, when the host serves it somewhere other
# than the engine's own /file/ path. nil leaves the client with what it already
# builds, which is the pre-existing behaviour.
attr_reader :gated, :entitled, :price_cents, :currency, :purchase_url, :web_url
def initialize(gated: false, entitled: true, price_cents: nil, currency: nil,
purchase_url: nil, web_url: nil)
@gated = gated ? true : false
@entitled = entitled
@price_cents = price_cents
@currency = currency
@purchase_url = purchase_url
@web_url = web_url
end
# An open catalog's answer, and the shape every response carries even when no
# policy is configured: a client should never have to tell "no access block" from
# "not gated". One of those is a question, the other is an answer.
OPEN = new.freeze
def as_json(*)
{
gated: gated,
entitled: entitled.nil? ? nil : (entitled ? true : false),
price: price_cents.nil? ? nil : { amountCents: price_cents, currency: currency },
purchaseUrl: purchase_url,
webUrl: web_url
}
end
# A download the policy allowed.
#
# `filename` and `expires_in` let a host override what the engine would otherwise
# decide on its own; both nil means "you choose", which is what the open policy says.
class Grant
attr_reader :filename, :expires_in
def initialize(filename: nil, expires_in: nil)
@filename = filename
@expires_in = expires_in
end
OPEN = new.freeze
end
end
end
@@ -26,6 +26,28 @@ module WarpEngine
# PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key).
# With neither set, POST /build/config rejects every request.
# ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url.
# access_policy: who may see a title and who may download it.
# :open (default) — every software listed, every artifact served, no prices:
# byte for byte the previous behaviour;
# any object — must answer visible_software_scope/access_for/
# authorize_download, see WarpEngine::AccessPolicy.
# access_token_owner_class: class name of the subject a *client* token belongs to
# (e.g. "Accounts::User"). nil (default) means no client sign-in: /api/auth/* is
# inactive and GET /api/service reports auth: null. Deliberately separate from
# application_token_owner_class, which owns *publishing* tokens — a publisher and
# a customer are rarely the same kind of thing.
# identity_verification_url: the host's own page where a person approves a device
# code. Path or absolute URL; nil falls back to "/devices". The page is the host's
# because approving needs a session, a login and HTML — none of which is the
# engine's business.
# subject_resolver: how to recognise a caller that is not carrying a bearer token —
# a browser with a session, typically. A callable taking the Rack request and
# returning the host's own subject object, or nil:
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
# nil (default) makes every non-bearer request anonymous, which is what the
# read-only API always did.
# device_code_ttl / device_code_interval: how long a pending device code lives, and
# how often a client is told to poll for it.
# woodpecker_url / woodpecker_api_token / woodpecker_repo_owner:
# Woodpecker CI management (repo sync, secret provisioning, pipeline control).
# All nil → the management features are inactive.
@@ -44,7 +66,13 @@ module WarpEngine
:woodpecker_api_token,
:woodpecker_repo_owner,
:image_owners,
:storage_adapter
:storage_adapter,
:access_policy,
:access_token_owner_class,
:subject_resolver,
:identity_verification_url,
:device_code_ttl,
:device_code_interval
def initialize
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@@ -63,6 +91,12 @@ module WarpEngine
@woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
@image_owners = []
@storage_adapter = :local
@access_policy = :open
@access_token_owner_class = nil
@subject_resolver = nil
@identity_verification_url = nil
@device_code_ttl = 600
@device_code_interval = 5
end
end
end
@@ -10,6 +10,15 @@ module WarpEngine
# A jövőbeli katalógus-migrációk az engine db/migrate-jéből futnak a host
# rails db:migrate-jével, másolás nélkül.
#
# FONTOS: emiatt az engine és a host migrációi EGY névtérben vannak, és két
# azonos verziószám a host `db:migrate`-jét indulás előtt megállítja
# (DuplicateMigrationVersionError) — nem a miénket, hanem az övét, az egész
# alkalmazásban. Az engine migrációi ezért **valódi, másodperc-pontosságú
# időbélyeget** kapnak (20260819093412), soha nem kerek kézzel írt számot
# (20260819000001): pont az utóbbiakra ír rá egy host, ami ugyanaznap ugyanezzel
# a szokással ír migrációt. Így ütközött a device_grants a katalógus-API
# `carry_the_store_config_in_the_registry`-jével.
initializer "warp_engine.append_migrations" do |app|
unless app.root.to_s.start_with?(root.to_s)
config.paths["db/migrate"].expanded.each do |path|
@@ -1,3 +1,8 @@
module WarpEngine
VERSION = "0.3.0"
VERSION = "0.5.1"
# The header every API response carries. Named here rather than written out at the one
# place that sets it: clients read it, the README documents it, and a string in three
# places is a string that eventually differs in one of them.
VERSION_HEADER = "WarpEngine-Version".freeze
end
+19 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
ActiveRecord::Schema[8.1].define(version: 2026_08_19_000001) do
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -29,6 +29,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "application_token_id", unsigned: true
t.datetime "approved_at", precision: 3
t.string "client_name", limit: 128
t.datetime "created_at", precision: 3
t.datetime "denied_at", precision: 3
t.string "device_code", limit: 64, null: false
t.datetime "expires_at", precision: 3, null: false
t.string "issued_token", limit: 64
t.bigint "subject_id", unsigned: true
t.string "subject_type", limit: 128
t.datetime "updated_at", precision: 3
t.string "user_code", limit: 16, null: false
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
t.index ["expires_at"], name: "idx_device_grants_expires_at"
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -0,0 +1,76 @@
require "rails_helper"
# The engine appends its `db/migrate` to the host's migration paths instead of copying
# migrations into the host, so the two share **one** version namespace. A collision does
# not fail politely somewhere in the engine: it stops the host's `db:migrate` before it
# runs anything, for the whole application.
#
# That is exactly what happened to `device_grants`. It was numbered 20260819000001 — a
# hand-picked round number — and the catalog API had written
# `20260819000001_carry_the_store_config_in_the_registry` on the same day with the same
# habit. Neither repository could see the other's number.
#
# The defence is that engine migrations carry a real second-resolution timestamp, which
# nobody hand-writes and nothing rounds to. This is the test that says so.
RSpec.describe "Engine migrations" do
# `202608050000 01`-style numbers: a date, then zeros, then a counter. Rails generates
# `20260819093412`; a person types this.
ROUND_VERSION = /\A\d{8}0{4}\d{2}\z/
# Numbered before this file existed and already deployed everywhere. Renumbering a
# migration that has run is worse than the risk it carries, so they are named here
# rather than quietly excluded by a rule that would also excuse the next one.
GRANDFATHERED = %w[
20260805000001
20260805000003
20260806000001
20260806000002
].freeze
let(:versions) do
Dir.glob(WarpEngine::Engine.root.join("db/migrate/*.rb"))
.map { |path| File.basename(path)[/\A\d+/] }
end
it "has a migration to check at all" do
expect(versions).not_to be_empty
end
it "numbers every migration uniquely" do
expect(versions).to eq(versions.uniq)
end
it "uses a real timestamp rather than a round hand-written number" do
round = versions.grep(ROUND_VERSION) - GRANDFATHERED
expect(round).to be_empty,
"these would collide with a host that numbers its migrations the " \
"same way on the same day: #{round.join(', ')}. Use a second-resolution " \
"timestamp — `date -u +%Y%m%d%H%M%S` — not a hand-picked round number."
end
# A table created by BOTH the install template and one of our own migrations is a
# second CREATE TABLE for the same name in whatever host installs us — the template
# runs, then the appended engine migration runs, and the second one fails.
#
# `application_tokens` is exactly that, and has been since before this file existed:
# every host installing today has to delete that block from its generated copy by
# hand, which is what teletype-orbit's own migration says in its header. It is
# grandfathered here rather than quietly excused, so the list can only shrink.
it "does not create a table the install generator also creates" do
template = WarpEngine::Engine.root.join(
"lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb"
)
engine_tables = versions.flat_map do |version|
file = Dir.glob(WarpEngine::Engine.root.join("db/migrate/#{version}_*.rb")).first
File.read(file).scan(/create_table :(\w+)/).flatten
end
template_tables = File.read(template).scan(/create_table :(\w+)/).flatten
duplicated = (engine_tables & template_tables) - [ "application_tokens" ]
expect(duplicated).to be_empty,
"the install template and an engine migration both create " \
"#{duplicated.join(', ')} — a host would run CREATE TABLE twice. " \
"A table added after the initial schema belongs in a migration only."
end
end
@@ -59,4 +59,42 @@ RSpec.describe WarpEngine::Pipeline do
describe "associations" do
it { is_expected.to belong_to(:software).optional }
end
# A software has one pipeline. Two pipelines pointing at the same one is not an error
# the database catches, it is a link that silently does nothing — so the newest
# assignment takes it, and says what it took it from.
describe "assigning a software another pipeline already has" do
it "moves the link and leaves the other pipeline without one" do
software = create(:software)
held_by = create(:pipeline, software: software, repo_owner: "games", repo_name: "old")
taking = create(:pipeline, repo_owner: "games", repo_name: "new")
taking.update!(software: software)
expect(taking.reload.software).to eq(software)
expect(held_by.reload.software).to be_nil
expect(software.reload.pipeline).to eq(taking)
end
it "names the pipelines it took the software from" do
software = create(:software)
create(:pipeline, software: software, repo_owner: "games", repo_name: "old")
taking = create(:pipeline, repo_owner: "games", repo_name: "new")
taking.update!(software: software)
expect(taking.software_taken_from).to eq([ "games/old" ])
end
it "leaves other pipelines alone when the software is cleared" do
software = create(:software)
keeps = create(:pipeline, software: software, repo_owner: "games", repo_name: "keeps")
other = create(:pipeline, repo_owner: "games", repo_name: "other")
other.update!(software: nil)
expect(keeps.reload.software).to eq(software)
expect(other.software_taken_from).to be_nil
end
end
end
@@ -0,0 +1,245 @@
require "rails_helper"
# Signing a client in, end to end: the client asks for a code, a person approves it on
# the host's page, the client's next poll carries the token away, and the token then
# works as a bearer credential on the read-only API.
RSpec.describe "Device sign-in", type: :request do
let(:owner) { create(:test_owner) }
# The identity seam is off by default. Configuring the subject class is what turns
# the whole flow on — including whether it exists at all.
def configure_identity!(verification: "/devices")
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
allow(WarpEngine.config).to receive(:identity_verification_url).and_return(verification)
end
describe "when the host configured no client identity" do
it "has no device endpoint at all" do
post "/api/auth/device", params: { client_name: "laptop" }
expect(response).to have_http_status(:not_found)
end
it "says so in the service descriptor rather than by erroring" do
get "/api/service"
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)["auth"]).to be_nil
end
end
describe "the full flow" do
before { configure_identity! }
it "issues a code pair a person can read off a screen" do
post "/api/auth/device", params: { client_name: "Zsolt's laptop" }
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json["deviceCode"]).to be_present
# Grouped and free of I/O/0/1, because it is typed by hand into a browser.
expect(json["userCode"]).to match(/\A[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}\z/)
expect(json["verificationUrl"]).to eq("http://www.example.com/devices")
expect(json["interval"]).to eq(5)
end
it "keeps the client waiting until somebody approves" do
post "/api/auth/device", params: { client_name: "laptop" }
device_code = JSON.parse(response.body)["deviceCode"]
post "/api/auth/device/token", params: { device_code: device_code }
expect(JSON.parse(response.body)).to eq("state" => "pending")
end
it "hands over the token on the first poll after approval" do
post "/api/auth/device", params: { client_name: "laptop" }
json = JSON.parse(response.body)
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
body = JSON.parse(response.body)
expect(body["state"]).to eq("approved")
expect(body["token"]).to be_present
end
# The plain token is never stored, so it cannot be handed out twice. A client that
# loses it starts again — which is cheaper than a database full of live secrets.
it "does not repeat the token on a second poll" do
post "/api/auth/device", params: { client_name: "laptop" }
json = JSON.parse(response.body)
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
body = JSON.parse(response.body)
expect(body["state"]).to eq("approved")
expect(body).not_to have_key("token")
end
it "reports a denied grant as denied" do
post "/api/auth/device", params: { client_name: "laptop" }
json = JSON.parse(response.body)
WarpEngine::DeviceGrantService.new.deny(user_code: json["userCode"])
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
expect(JSON.parse(response.body)["state"]).to eq("denied")
end
it "reports an expired grant as expired" do
post "/api/auth/device", params: { client_name: "laptop" }
json = JSON.parse(response.body)
WarpEngine::DeviceGrant.last.update!(expires_at: 1.minute.ago)
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
expect(JSON.parse(response.body)["state"]).to eq("expired")
end
it "404s an unknown device code" do
post "/api/auth/device/token", params: { device_code: "nope" }
expect(response).to have_http_status(:not_found)
end
it "will not approve the same code twice" do
post "/api/auth/device", params: { client_name: "laptop" }
code = JSON.parse(response.body)["userCode"]
WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner)
expect { WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner) }
.to raise_error(WarpEngine::DeviceGrantService::UnknownCode)
end
it "accepts the user code however a person typed it" do
post "/api/auth/device", params: { client_name: "laptop" }
code = JSON.parse(response.body)["userCode"]
grant = WarpEngine::DeviceGrantService.new.approve(
user_code: code.downcase.delete("-"), subject: owner
)
expect(grant).to be_approved
end
end
describe "the issued token" do
before { configure_identity! }
let(:token) do
post "/api/auth/device", params: { client_name: "laptop" }
json = JSON.parse(response.body)
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
JSON.parse(response.body)["token"]
end
it "belongs to the subject who approved it, and may only read the catalog" do
token
record = WarpEngine::ApplicationToken.last
expect(record.owner).to eq(owner)
expect(record.scopes).to eq([ "catalog" ])
end
# A publishing token must not become a client token by accident, and vice versa:
# the scope is what separates them, and the catalog endpoint requires its own.
it "is not accepted as a publishing credential" do
token
expect(WarpEngine::ApplicationToken.authenticate(token, required_scope: "update")).to be_nil
end
it "identifies the subject on a catalog request" do
create(:software)
seen = nil
policy = Class.new do
def initialize(sink) = @sink = sink
def visible_software_scope(subject: nil) = WarpEngine::Software.all
def access_for(software:, subject: nil)
@sink.call(subject)
WarpEngine::Access::OPEN
end
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
end.new(->(s) { seen = s })
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
get "/api/software", headers: { "Authorization" => "Bearer #{token}" }
expect(seen).to eq(owner)
end
it "is ignored when it is not a bearer credential" do
value = token
get "/api/software", headers: { "Authorization" => value }
expect(response).to have_http_status(:ok)
expect(WarpEngine::ApplicationToken.last.last_used_at).to be_nil
end
it "stops working once revoked" do
value = token
delete "/api/auth/token", headers: { "Authorization" => "Bearer #{value}" }
expect(response).to have_http_status(:no_content)
expect(WarpEngine::ApplicationToken.authenticate(value, required_scope: "catalog")).to be_nil
end
it "refuses to revoke without a token" do
delete "/api/auth/token"
expect(response).to have_http_status(:unauthorized)
end
end
# A browser carries a session, not a bearer token, and the engine has no idea what a
# session is. A host that wants its signed-in visitors recognised says how.
describe "the host's own subject resolver" do
let(:seen) { [] }
let(:policy) do
sink = seen
Class.new do
def initialize(sink) = @sink = sink
def visible_software_scope(subject: nil) = WarpEngine::Software.all
def access_for(software:, subject: nil)
@sink << subject
WarpEngine::Access::OPEN
end
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
end.new(sink)
end
before do
create(:software)
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
end
it "is asked when there is no bearer token" do
allow(WarpEngine.config).to receive(:subject_resolver).and_return(->(_request) { owner })
get "/api/software"
expect(seen).to eq([ owner ])
end
it "leaves the request anonymous when the host configured none" do
get "/api/software"
expect(seen).to eq([ nil ])
end
it "answers anonymously rather than erroring when the resolver breaks" do
allow(WarpEngine.config).to receive(:subject_resolver).and_return(->(_request) { raise "boom" })
get "/api/software"
expect(response).to have_http_status(:ok)
expect(seen).to eq([ nil ])
end
end
end
@@ -0,0 +1,75 @@
require "rails_helper"
# The descriptor is how a client stops being built for one particular store: everything
# it used to have compiled in — is there a sign-in, where does it live, can titles be
# gated — is answered here instead.
RSpec.describe "GET /api/service", type: :request do
after { WarpEngine::AccessPolicy.reset! }
it "names the engine and its version" do
get "/api/service"
json = JSON.parse(response.body)
expect(json["engine"]).to eq("warp_engine")
expect(json["version"]).to eq(WarpEngine::VERSION)
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
it "reports an open catalog as ungated and offering no sign-in" do
get "/api/service"
json = JSON.parse(response.body)
expect(json["catalog"]).to eq("gated" => false)
expect(json["auth"]).to be_nil
end
it "reports a configured policy as a catalog that can gate" do
policy = Class.new do
def visible_software_scope(subject: nil) = WarpEngine::Software.all
def access_for(software:, subject: nil) = WarpEngine::Access::OPEN
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
end.new
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
get "/api/service"
expect(JSON.parse(response.body)["catalog"]).to eq("gated" => true)
end
describe "with a client identity configured" do
before do
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
allow(WarpEngine.config).to receive(:identity_verification_url).and_return("/devices")
end
it "describes the device flow, so a client needs no addresses of its own" do
get "/api/service"
auth = JSON.parse(response.body)["auth"]
expect(auth["schemes"]).to eq([ "bearer" ])
expect(auth["device"]["authorizeUrl"]).to eq("http://www.example.com/api/auth/device")
expect(auth["device"]["tokenUrl"]).to eq("http://www.example.com/api/auth/device/token")
expect(auth["device"]["revokeUrl"]).to eq("http://www.example.com/api/auth/token")
expect(auth["device"]["interval"]).to eq(5)
end
# A host that configured a bare path should not have to know its own hostname; one
# that put the approval page on another domain should keep it.
it "makes a configured path absolute against the request" do
get "/api/service"
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
.to eq("http://www.example.com/devices")
end
it "leaves an absolute verification URL alone" do
allow(WarpEngine.config).to receive(:identity_verification_url)
.and_return("https://accounts.example.org/devices")
get "/api/service"
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
.to eq("https://accounts.example.org/devices")
end
end
end
@@ -0,0 +1,36 @@
require "rails_helper"
# Every response the engine serves carries the version that served it, so a client can
# branch on the engine's age without asking a separate endpoint for it.
RSpec.describe "the WarpEngine-Version header", type: :request do
# The controller writes the name as a literal so that it cannot depend on a constant a
# half-updated deploy might not have. This is what keeps the two in step.
it "is the name the constant documents" do
expect(WarpEngine::VERSION_HEADER).to eq("WarpEngine-Version")
end
it "is on a normal response" do
create(:software)
get "/api/software"
expect(response).to have_http_status(:ok)
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
# The one a client needs most: something came back wrong, and it wants to know whether
# the engine on the other end is old enough to explain it. `rescue_from` never reaches
# an after_action, which is why the header is set before the action runs.
it "is on an error response" do
get "/api/image/999999"
expect(response).to have_http_status(:not_found)
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
it "is on a served file" do
get "/file/nothing-here.zip"
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
end
end
@@ -0,0 +1,174 @@
require "rails_helper"
require "tmpdir"
# The access seam, from both sides: what the catalog says about a title, and whether an
# artifact is handed over. The load-bearing case is the *default* one — a catalog with
# no policy configured has to behave exactly as it did before this existed.
RSpec.describe "The access policy" do
let(:tmpdir) { Dir.mktmpdir }
# A policy that gates everything except what the subject is named after. Small enough
# to read, and it exercises every method of the contract.
let(:gating_policy) do
Class.new do
def initialize(open_name) = @open_name = open_name
def visible_software_scope(subject: nil)
WarpEngine::Software.where.not(status: "development")
end
def access_for(software:, subject: nil)
return WarpEngine::Access.new if software.name == @open_name
WarpEngine::Access.new(
gated: true, entitled: subject.present?, price_cents: 1490, currency: "EUR",
purchase_url: "https://shop.example/#{software.name}",
web_url: "https://shop.example/play/#{software.name}"
)
end
def authorize_download(asset: nil, subject: nil, request: nil)
return WarpEngine::Access::Grant.new if asset&.release&.software&.name == @open_name
subject.nil? ? nil : WarpEngine::Access::Grant.new
end
end
end
before do
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
WarpEngine::Storage.reset!
WarpEngine::AccessPolicy.reset!
end
after do
FileUtils.rm_rf(tmpdir)
WarpEngine::AccessPolicy.reset!
end
describe "the default (:open) policy" do
it "lists every software, whatever its status" do
create(:software, status: "development")
create(:software, status: "released")
result = WarpEngine::SoftwareService.new.index
expect(result[:softwares].size).to eq(2)
end
it "reports every title as open, so a client never has to guess" do
create(:software)
entry = WarpEngine::SoftwareService.new.index[:softwares].first
expect(entry[:access]).to eq(
gated: false, entitled: true, price: nil, purchaseUrl: nil, webUrl: nil
)
end
it "hands over an artifact with no subject at all" do
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
path = WarpEngine::DownloadService.new.create(
path: "game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
)
expect(path).to eq(File.join(tmpdir, "game-1.0.zip"))
end
end
describe "a configured policy" do
let(:open_software) { create(:software, name: "free-game", status: "released") }
let(:gated_software) { create(:software, name: "paid-game", status: "released") }
let(:subject_record) { create(:test_owner) }
before do
open_software
gated_software
create(:software, name: "draft-game", status: "development")
allow(WarpEngine.config).to receive(:access_policy).and_return(gating_policy.new("free-game"))
end
it "narrows the catalog to what the policy scope allows" do
names = WarpEngine::SoftwareService.new.index[:softwares].map { |e| e[:software][:name] }
expect(names).to contain_exactly("free-game", "paid-game")
end
it "describes a gated title with its price and where to buy it" do
entry = WarpEngine::SoftwareService.new.index[:softwares]
.find { |e| e[:software][:name] == "paid-game" }
expect(entry[:access]).to eq(
gated: true, entitled: false,
price: { amountCents: 1490, currency: "EUR" },
purchaseUrl: "https://shop.example/paid-game",
webUrl: "https://shop.example/play/paid-game"
)
end
it "reports entitlement against the authenticated subject" do
entry = WarpEngine::SoftwareService.new.index(subject: subject_record)[:softwares]
.find { |e| e[:software][:name] == "paid-game" }
expect(entry[:access][:entitled]).to be(true)
end
it "refuses an artifact the policy will not authorise" do
File.write(File.join(tmpdir, "paid-game-1.0.zip"), "zip")
release = create(:release, software: gated_software)
WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64",
path: File.join(tmpdir, "paid-game-1.0.zip"))
expect {
WarpEngine::DownloadService.new.create(
path: "paid-game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
)
}.to raise_error(WarpEngine::DownloadService::Denied)
end
it "hands the same artifact over to a subject the policy accepts" do
File.write(File.join(tmpdir, "paid-game-1.0.zip"), "zip")
release = create(:release, software: gated_software)
WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64",
path: File.join(tmpdir, "paid-game-1.0.zip"))
path = WarpEngine::DownloadService.new.create(
path: "paid-game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil,
subject: subject_record
)
expect(path).to eq(File.join(tmpdir, "paid-game-1.0.zip"))
end
end
describe "a policy that raises" do
let(:broken_policy) do
Class.new do
def visible_software_scope(subject: nil) = raise("boom")
def access_for(software:, subject: nil) = raise("boom")
def authorize_download(asset: nil, subject: nil, request: nil) = raise("boom")
end.new
end
before { allow(WarpEngine.config).to receive(:access_policy).and_return(broken_policy) }
# The direction of the failure is the point. A broken gatekeeper must not become an
# open one: an empty catalog is recoverable, a paid title given away is not.
it "empties the catalog rather than leaking it" do
create(:software, status: "released")
expect(WarpEngine::SoftwareService.new.index[:softwares]).to be_empty
end
it "refuses the download rather than serving it" do
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
expect {
WarpEngine::DownloadService.new.create(
path: "game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
)
}.to raise_error(WarpEngine::DownloadService::Denied)
end
end
end