EN /HU | Login

Teletype Orbit

Teletype Orbit is a game shop like itch.io or Steam: a Rails 8 + Hotwire application built on the WarpEngine catalog engine. WarpEngine owns the catalog — software, releases, artifacts, CI publishing. Orbit owns what a shop needs on top: price, order, entitlement, publisher, moderation, payout.

Where it stands. Orbit runs locally only: the server instance was retired on 2026-09-11, and its source is kept on the server under /srv/stacks_disabled. The code keeps up with the engine — it is on WarpEngine 1.5.1 since 2026-10-26.

What it adds to WarpEngine

Area What Orbit brings
Shop catalog pages, search, filters, product page, cart, checkout, orders
Library entitlements, gated downloads, keys, gifts
Play web-playable builds behind an entitlement check
Studio publisher portal: product, price, store page, images, channels, publishing tokens, hosted builds
Moderation submissions, reports, repository approval
Community reviews, forum, wishlist, following, activity
Finance append-only ledger, revenue share, payouts, invoices

The engine's words stay the engine's. A Software is the technical catalog entry, a Release is a build version. The sellable thing is a Store::Product, and the customer-facing build pointer is a Studio::Channel. Only an id links the two, with no foreign key — the engine soft-deletes, so its rows can vanish underneath.

The download gate

Before. WarpEngine's /api/download and /file/* did no authentication, so Orbit shadowed both with routes placed before mount WarpEngine::Engine. It worked, but it depended on route order — a defence that breaks silently when a line moves.

Now. Since WarpEngine 0.5.0 the engine asks an access policy first, and Orbit's is Library::AccessPolicy:

Method What it does
visible_software_scope narrows GET /api/software to Store::Product.listed, so drafts, in-review, delisted and private products are not listed
access_for attaches the price, the purchase page, and whether this caller owns it
authorize_download runs the rules Library::DownloadService already had: entitlement, rate limit, grant, download event

/api/download is no longer shadowed. /file/* still is, on purpose: a web build runs in a browser, where the credential is the session and the right answer to "not yours" is a redirect to login, not a bare 403. The engine has neither.

The risk changed shape rather than disappearing — a policy can fail to be wired up as silently as route order could. spec/requests/library/download_gate_spec.rb is the regression test, and it is not optional.

Signing in from a client

Orbit configures WarpEngine's device authorization grant, so the desktop WarpEngine Client can hold an account:

RUBY
c.access_token_owner_class  = "Accounts::User"
c.identity_verification_url = "/devices"
c.subject_resolver = ->(request) { request.env["warden"]&.user(:user) }

The engine owns the flow. The two pages are Orbit's, because approving needs a session and a logged-in human:

Page What it does
/devices a person types the short code the client shows, sees which device is asking, approves or refuses
/client_tokens the devices signed in to this account, and removing one

subject_resolver is the other half: a browser carries a session, not a bearer token, and without it the engine would refuse Orbit's own signed-in visitors.

A client is told what it needs and nothing more. Price, ownership, where to buy, whether sign-in exists — all of it comes from the engine's generic contract, so the client carries no Orbit-specific code. One client must serve any WarpEngine catalog; a rule compiled into it would break every other one.

One Orbit-side bug worth remembering

Every WarpEngine::Software was created with status "development" and nothing moved it, because nothing in Orbit reads that field — its pages ask the product. A catalog client does read it: the desktop store lists released and archived titles and skips the rest, so the whole catalog was invisible to it. Store::Product now keeps the two in step (published → released, delisted → archived, demo product → demo) with an after_save, not a line in a service, because the state changes from the moderation flow and from ActiveAdmin alike.

Upgrading the engine: 0.8 to 1.5

The jump brought the engine's migrations (the category column, the status and category vocabulary, shelf_name), which a db:migrate runs, and two things the host had to do itself:

  • .kept everywhere. Since 0.10 the engine models have no default_scope, so a query on Software, Release, ReleaseAsset, ApplicationToken, Pipeline, PlatformLink or Download sees soft-deleted rows too. Orbit relied on the old scope in about twenty places — a revoked device token stayed on the devices page — and every one of them asks for .kept now. The privacy erasure is the exception on purpose: it anonymizes the download rows of deleted assets as well.
  • The CI platforms carry the host's values. The c64 recipe packages its players in an exporter image (commodore-player) since 0.11, and since 1.5 the bevy, ebitengine and phaser web shells and the LÖVE bundle id prefix come from the platform entries, with the values the engine used to bake in.

Running it

Everything is in Compose, app and Sidekiq worker included:

SH
cp .env.example .env
make up
docker compose exec app bin/rails db:seed
Surface URL
Shop http://localhost:9140
Admin http://localhost:9140/admin
Mail (Mailpit) http://localhost:8025
MinIO console http://localhost:9001

Every published port can be moved in .env (ORBIT_WEB_PORT and the rest), and the infrastructure listens on 127.0.0.1 only.

  • MinIO comes from Chainguard (cgr.dev/chainguard/minio): MinIO stopped publishing images in 2025, and minio/minio is gone from Docker Hub. It is the same server built from source, :latest only, running as uid 65532 — an old root-owned volume needs docker compose down -v or a chown. minio-init creates the two private buckets with one mc mb -p, retrying until the server answers.
  • The css service keeps watching. Tailwind 4's watcher exits when stdin closes, which a container without a TTY does at once; bin/dev-css runs both bundles' watchers with [always]. A folder created while it runs is picked up after docker compose restart css.

Seeding has two levels:

  • make seed-core — reference data that also runs in production: genres, tags, EU VAT rates, revenue shares, forums.
  • make seed-examples — development-only samples. They go through the real path (Studio → submission → approval → cart → payment webhook), so the ledger and the reports hold meaningful data.

Conventions

The app follows Growing Rails Applications in Practice. Two rules do most of the work:

  • A controller is trivial and has only standard actions. Approving a device is DeviceApprovalsController#create, not DevicesController#approve. A download is a resource whose creation issues the grant.
  • Logic lives in services, one public method per use case, with the specs weighted there: every branch, every failure path.

The full plan and the reasoning behind the decisions are in NOTES_TELETYPE_ORBIT.md in the repository.