---
title: 'Teletype Orbit'
url: 'https://wiki.teletypegames.org/services/teletype-orbit'
markdown: 'https://wiki.teletypegames.org/services/teletype-orbit.md'
lang: en
date: '2026-08-19'
description: '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. W…'
taxonomy:
  tag:
    - howto
---

# Teletype Orbit

> Teletype Orbit is a game shop like itch.io or Steam: a Rails 8 + Hotwire application built on the [WarpEngine](https://wiki.teletypegames.org/engines/warp-engine) 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](https://wiki.teletypegames.org/engines/warp-engine) 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](https://wiki.teletypegames.org/stores/warp-engine-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.

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [Services](https://wiki.teletypegames.org/services.md)
- Previous: [Teletype BBS](https://wiki.teletypegames.org/services/teletype-bbs.md)
- Next: [TTG Blog BBS](https://wiki.teletypegames.org/services/ttg-blog-bbs.md)
