mr.zeroandClaude Opus 5 06030c43cd The dummy schema still stamped the old migration version
CI builds the engine's test database from spec/dummy/db/schema.rb, and that file
still said `version: 2026_08_19_000001`. Loading it marks every migration up to
that number as applied — which no longer includes device_grants at
20260819093412 — so rspec aborted with a pending migration before running
anything, and both the push and the tag pipeline failed.

It passed on my machine because I had renumbered the row in the live test
database by hand and never re-dumped the schema. Verified this time the way CI
does it: dropped the database, `rake app:db:test:prepare` from schema.rb, 227
examples green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:39:45 +02:00
2026-08-06 14:08:18 +02:00
2026-08-06 13:58:58 +02:00

WarpEngine

A mountable Rails engine that turns any Rails application into a retro 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/engines/warp_engine

Features

  • Catalog domain: Software, Release, ReleaseAsset, ExternalLink, PlatformLink, Image, SoftwareImage, Download models with soft-delete semantics and download statistics.
  • CI-callable updater: your build pipeline uploads artifacts over HTTP and calls one endpoint — WarpEngine extracts archives, parses metadata and upserts the catalog records. Supported platforms out of the 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.
  • Publish events: every published release emits ActiveSupport::Notifications (warp_engine.publish), so hosts can react to new builds without model callbacks.
  • Public JSON API: catalog listing, highlighted title, per-platform build matrix, image serving, download tracking, and a static file server for web-playable builds.
  • Admin (optional): if the host runs ActiveAdmin, WarpEngine contributes ready-made resources — a catalog editor with nested release/asset forms, an image library with orphan cleanup, a file manager with a picker mode, and download statistics. Without ActiveAdmin the engine runs headless (API + updater only).
  • Woodpecker CI management (optional): with a Woodpecker API token configured, the admin also gains repo sync, per-repo pipeline history with manual triggers, and automatic provisioning of application tokens as Woodpecker secrets.

Requirements

  • Rails >= 8.0
  • A relational database (developed and tested against MySQL 8)
  • Optional: ActiveAdmin + Devise in the host app for the admin UI

Example stack (docker compose)

examples/compose boots everything the engine's workflow assumes, end to end: the catalog app itself and — behind a compose profile — a Gitea forge with Woodpecker CI, so you can watch a pipeline publish a release into the catalog.

Service Role Where
app Minimal Rails host with the engine mounted as a path gem (headless: API + updater) http://localhost:8080
mysql Catalog database internal
gitea Git forge (profile ci) http://gitea:3000
woodpecker + agent CI wired to gitea (profile ci) http://woodpecker:8000

Quickstart — catalog only

cd examples/compose
cp .env.example .env   # defaults work for a throwaway local demo
docker compose up --build

The first boot takes a few minutes: the app container bundles, runs rails g warp_engine:install and rails db:prepare, then serves on http://localhost:8080:

  • http://localhost:8080/api/software — the (empty) catalog
  • http://localhost:8080/api/builds — the platform build matrix
  • http://localhost:8080/api/docs — apipie API docs

Publish a release by hand

The updater contract is nothing but a handful of HTTP calls, so you can play the role of the CI pipeline yourself:

# 1. Fake a build: metadata, a web build and a windows artifact, named by
#    convention (the love platform requires the .html.zip web build)
cat > demo-0.1.0.metadata.json <<'JSON'
{ "name": "demo", "title": "Demo Game", "author": "You", "desc": "Hello", "license": "MIT" }
JSON
echo '<h1>demo</h1>' > index.html && zip demo-0.1.0.html.zip index.html
echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin

# 2. Upload them (one request per file)
for f in demo-0.1.0.*; do
  curl -fs -H "X-Update-Secret: example-update-secret" \
    -F "file=@$f" "http://localhost:8080/build/upload?name=demo&version=0.1.0"
done

# 3. Publish the release
curl -X POST -H "X-Update-Secret: example-update-secret" \
  "http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"

GET /api/software now lists Demo Game with html and win_x64 assets, http://localhost:8080/file/demo-0.1.0/index.html serves the extracted web build, and GET /api/download?path=demo-0.1.0-win-x64.zip serves the artifact while logging a download record.

Full loop — forge + CI (profile ci)

gitea and woodpecker address each other by service name, so let your browser resolve those names too:

echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
  1. docker compose --profile ci up -d gitea, open http://gitea:3000, finish the install wizard (SQLite is fine) and create your admin user.
  2. In gitea: Settings → Applications → Manage OAuth2 Applications, create an app with redirect URI http://woodpecker:8000/authorize; copy the client id/secret into WOODPECKER_GITEA_CLIENT / WOODPECKER_GITEA_SECRET in .env.
  3. docker compose --profile ci up -d — then log in at http://woodpecker:8000 (OAuth via gitea) and enable your repository.

A pipeline publishes a release exactly like the by-hand steps above — build, upload, publish:

# .woodpecker.yaml in a game repo hosted on the example gitea
steps:
  publish:
    image: alpine
    environment:
      UPDATE_SECRET:
        from_secret: update_secret
    commands:
      - apk add --no-cache curl zip
      - # ... build your game, produce mygame-1.0.0.metadata.json + artifacts ...
      - for f in mygame-1.0.0.*; do curl -fs -H "X-Update-Secret: $UPDATE_SECRET" -F "file=@$f" "http://app:3000/build/upload?name=mygame&version=1.0.0"; done
      - curl -fs -X POST -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/build/publish?platform=love&name=mygame&version=1.0.0"

(The agent attaches pipeline containers to the stack network, so app resolves. For real projects, the per-platform tools/*-tools repos ship ready-made Makefile + pipeline templates implementing this contract.)

Tear the stack down with docker compose --profile ci down -v.

Installation

From the git repository:

# Gemfile
gem "warp_engine", git: "https://git.teletypegames.org/engines/warp_engine.git"

Or from the Forgejo rubygems registry, which is where tagged releases land — a version rather than whatever a branch happens to hold:

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:

rails g warp_engine:install   # initializer + create_warp_engine_tables migration
rails db:migrate
# config/routes.rb — keep it the last entry so your own routes win
mount WarpEngine::Engine => "/"

Configuration

# config/initializers/warp_engine.rb
Rails.application.config.to_prepare do
  WarpEngine.configure do |c|
    # Where CI drops build artifacts and where images are stored
    c.file_container_path  = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
    c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")

    # Shared secret for the /build/* endpoints.
    # nil => the endpoints reject every request.
    c.update_secret = ENV["UPDATE_SECRET"]

    # Authentication source for /build/* — an exclusive choice:
    #   :env      — the shared secret above is accepted (default)
    #   :database — only WarpEngine::ApplicationToken records with the
    #               "update" scope are accepted; the shared secret stops
    #               working the moment you switch.
    # :database mode also requires the owner class every token belongs to:
    # c.application_token_source = :database
    # c.application_token_owner_class = "AdminUser"

    # Size cap for /build/upload and the admin file manager, in bytes (default 500MB).
    # c.max_upload_size = 500 * 1024 * 1024

    # Owner isolation: a database token may only upload/publish softwares
    # owned by its own owner (unrestricted tokens are exempt). Enable only
    # 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 = [
    #   {
    #     label: "member",
    #     image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
    #     usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
    #   }
    # ]
  end
end

The updater contract

Publishing a release from CI is two steps:

  1. Upload build artifacts into file_container_path, named by convention: <name>-<version>.metadata.json, <name>-<version>.html.zip, <name>-<version>-win-x64.zip, <name>-<version>.tic, ... (each platform declares which asset kinds it expects — see GET /api/builds). Push them over HTTP — one request per file, upload scope, optional sha256 integrity check:

    curl -H "X-Update-Secret: $UPDATE_SECRET" \
      -F "file=@mygame-1.2.0.html.zip" \
      "https://your-host/build/upload?name=mygame&version=1.2.0"
    
  2. Publish the release:

    curl -X POST -H "X-Update-Secret: $UPDATE_SECRET" \
      "https://your-host/build/publish?platform=tic80&name=mygame&version=1.2.0"
    

WarpEngine extracts the archives, parses the metadata (JSON, or the Lua comment header for TIC-80), and upserts the Software, ExternalLink, Release and ReleaseAsset records in a single transaction. Previously deleted records are resurrected on re-ingest.

Updater authentication

The X-Update-Secret header carries one of two credentials, selected by application_token_source — the modes are exclusive, the endpoint never accepts both:

  • :env (default): the single shared secret from update_secret.
  • :database: WarpEngine::ApplicationToken records. Each token belongs to an owner (the class named by application_token_owner_class, e.g. AdminUser), carries a free-form scope list — publishing requires the "update" scope, /build/upload the "upload" scope — and an optional expiry. Tokens are created in the admin (App Tokens): the plain token is generated server-side and shown exactly once after creation; only its SHA256 digest is stored. Deleting a token in the admin revokes it (soft delete), and last_used_at records when each token last authenticated successfully.

When switching to :database, create the tokens and move your pipelines to them first — the flip invalidates the shared secret immediately.

CI pipeline configs (Woodpecker)

WarpEngine can act as a Woodpecker configuration extension: instead of a copy-pasted .woodpecker.yaml in every game repo, the repo holds a one-line marker and the engine serves the full per-platform pipeline (version → build → upload → publish, calling /build/upload + /build/publish with the application_token Woodpecker secret):

# .woodpecker.yaml in a game repo
platform: godot
  • POST /build/config — the extension endpoint Woodpecker calls on every pipeline start (httpsig/ed25519-signed request, verified against ci_extension_public_key(_url)). Non-marker configs get a 204 so the repo's own YAML keeps running — opt-in migration, and putting a full pipeline back into the repo is the opt-out.
  • GET /build/config?platform=godot — renders the same pipeline as a preview.

Configuration: ci_platforms maps platform names to builder images ({ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }); an empty map (default) disables the feature. Set the Woodpecker side with WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config (or per-repo in Settings → Extensions). Templates live in app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb.

Woodpecker CI management

Beyond serving pipeline configs, WarpEngine can drive the Woodpecker REST API itself. Set woodpecker_url and woodpecker_api_token — while either is nil (the default), every management feature stays inactive and the admin pages hide themselves:

c.woodpecker_url        = ENV["WOODPECKER_URL"]        # e.g. "https://ci.example.org"
c.woodpecker_api_token  = ENV["WOODPECKER_API_TOKEN"]  # PAT of a Woodpecker *instance admin*
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org the game repos live under

What it unlocks (all surfaced in the admin):

  • Repo sync (Pipelines → Sync from Woodpecker): mirrors the Woodpecker repo list into Pipeline records, auto-matching each repo to a catalog Software by name; repos that disappear from Woodpecker are deactivated. Platform and software links are editable by hand afterwards.
  • Pipeline history: each entry on the Pipelines page lists its recent runs with a manual Trigger action; the newest run refreshes the cached last-pipeline status shown on the Pipelines index. The software's admin page links to its pipelines from the Quick Links sidebar.
  • Secret provisioning: database application tokens are pushed to the repos as the application_token Woodpecker secret — creating a token provisions it to its owner's repos (unrestricted tokens to all active repos), deleting a token removes the secret, and Rotate creates a replacement token, provisions it everywhere and revokes the old one in a single step.

The API token must belong to a Woodpecker instance admin — listing the server's repos is an admin-only endpoint (anything less yields 403 User not authorized). Add the user to WOODPECKER_ADMIN on the Woodpecker server, then log out and back in: the admin flag is written to the user record at login, a server restart alone is not enough.

Storage

Build artifacts are served through a storage adapter. The default is the local filesystem under file_container_path — byte for byte the behaviour the engine always had:

c.storage_adapter = :local   # default

A host that keeps its artifacts elsewhere (an object store behind a CDN, for example) can plug in its own object instead of patching the engine. The contract is three methods:

class MyObjectStore
  def file?(relative_path)      = ...   # true/false
  def directory?(relative_path) = ...   # true/false

  # Return a WarpEngine::Storage::Location:
  #   Location.file(absolute_path)  — the engine will send_file it
  #   Location.redirect(url)        — the engine will redirect (signed URL)
  def locate(relative_path, filename: nil, expires_in: nil) = ...
end

c.storage_adapter = MyObjectStore.new

GET /api/download and GET /file/* both go through the adapter, so a signing adapter turns them into redirects without any further change. WarpEngine::DownloadService#create still returns an absolute path (and nil when there is none), so existing callers keep working; #locate is the new entry point that can also hand back a redirect.

Serving only. Ingestion — POST /build/upload, archive extraction and 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:

c.access_policy = :open   # default

A host that sells supplies a policy instead. The contract is three methods:

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":

"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:

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 POSTs /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:

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 can react to a new build without hanging a callback on the models:

ActiveSupport::Notifications.subscribe("warp_engine.publish") do |*, payload|
  payload[:software]  # WarpEngine::Software
  payload[:release]   # WarpEngine::Release
  payload[:platform]  # "godot"
  payload[:name]      # "mygame"
  payload[:version]   # "1.2.0"
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/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

The host owns the single ActiveAdmin instance — authentication (Devise), theme, assets and the /admin routes. WarpEngine only appends its resource files to ActiveAdmin.application.load_paths. Two things to copy into a new host:

  • the small file-picker JS for release-asset path inputs (an iframe pointing at /admin/files?picker=1&field=<dom_id>) in your active_admin.js;
  • if you generate apipie docs, add "#{WarpEngine::Engine.root}/app/controllers/**/*.rb" to your api_controllers_matcher.

Behavioral notes

  • Every model is soft-deleted (default_scope { where(deleted_at: nil) }).
  • The JSON shape is stable and intentionally bug-compatible with the project's 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

The engine ships an RSpec suite running against a bundled dummy app:

bundle install
bundle exec rake app:db:prepare RAILS_ENV=test
bundle exec rspec

Development

This repository is a read-only split mirror — development happens in the tools/teletypegames monorepo under libs/ruby/warp_engine, and CI republishes the mirror on every change. Please do not open pull requests against the mirror.

S
Description
No description provided
Readme
478 KiB
Languages
Ruby 81.5%
HTML 18.5%