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>
This commit is contained in:
2026-08-18 16:53:33 +02:00
co-authored by Claude Opus 5
parent 5517133e25
commit 466aeac6ca
12 changed files with 137 additions and 26 deletions
+8 -4
View File
@@ -35,8 +35,10 @@ ActiveAdmin.register Store do
row :updated_at
end
para do
"Listed by GET /api/stores, which the desktop client reads on first run: it " \
"takes the config from the store repository and points it at the catalog above."
"Listed by GET /api/stores, which the desktop client reads on first run. With a " \
"store repository the client takes that config and points it at the catalog " \
"above; without one it uses the store engine's defaults, which need nothing " \
"but the name and the catalog."
end
end
@@ -45,8 +47,10 @@ ActiveAdmin.register Store 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"
f.input :store_repository_url,
hint: "Repository holding the store's config.json, e.g. " \
"https://git.teletypegames.org/stores/ttg-desktop-store"
hint: "Optional. A repository holding the store's config.json, e.g. " \
"https://git.teletypegames.org/stores/ttg-desktop-store. Leave it " \
"empty and the client uses the engine's defaults with the name and " \
"catalog above"
end
f.actions
end
@@ -6,13 +6,19 @@ class Api::StoresController < ApiController
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, and where each one's store configuration lives. Public on purpose —
a client has nobody to log in as.
exist, and where there is one the repository holding a store's own
configuration. Public on purpose: a client has nobody to log in as.
A record needs a name and a catalog URL. `storeRepositoryUrl` is optional and
answers `null` when there is none; a client then takes identity from this
record and everything else from the store engine's built-in defaults, so no
repository has to exist for a store to be installable.
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"
property :storeRepositoryUrl, String, desc: "Repository holding the store's config.json"
property :storeRepositoryUrl, [String, nil],
desc: "Repository holding the store's config.json, or null when it has none"
end
def index
render json: StoreService.new.index
+10 -3
View File
@@ -1,15 +1,22 @@
# A store a client can install from: a WarpEngine catalog, and the repository
# holding the store's own configuration.
# A store a client can install from: a WarpEngine catalog, and — optionally — a
# repository holding that store's own configuration.
#
# 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.
#
# The repository is optional because a store does not need one. A client takes
# identity from this record — the name, the catalog and a slug derived from it —
# and everything else from the store engine's built-in defaults. A repository is
# still honoured when given: it remains the authority on how that store behaves,
# which platforms it offers and where things land.
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" }
validates :store_repository_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
validates :store_repository_url, format: { with: URL, message: "must be an http(s) URL" },
allow_blank: true
default_scope { where(deleted_at: nil) }
+3 -1
View File
@@ -5,5 +5,7 @@ class StoreSerializer < Blueprinter::Base
# camelCase, as the catalog's own payloads use — one convention for a client
# that reads both.
field(:catalogUrl) { |store| store.catalog_url }
field(:storeRepositoryUrl) { |store| store.store_repository_url }
# Null rather than an empty string when there is no repository: the client
# branches on its absence, and "" is not an absence a JSON reader can trust.
field(:storeRepositoryUrl) { |store| store.store_repository_url.presence }
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
+2 -2
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_18_000001) do
ActiveRecord::Schema[8.1].define(version: 2026_08_18_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
@@ -286,7 +286,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_18_000001) do
t.datetime "created_at", precision: 3, null: false
t.datetime "deleted_at", precision: 3
t.string "name", null: false
t.string "store_repository_url", null: false
t.string "store_repository_url"
t.datetime "updated_at", precision: 3, null: false
t.index ["deleted_at"], name: "idx_stores_deleted_at"
t.index ["name"], name: "idx_stores_name"
@@ -24,6 +24,14 @@ RSpec.describe Api::StoresController, type: :request do
expect(store["storeRepositoryUrl"]).to end_with("/stores/ttg-desktop-store")
end
it "answers null for a store with no repository" do
create(:store, store_repository_url: nil)
get "/api/stores"
expect(JSON.parse(response.body).first["storeRepositoryUrl"]).to be_nil
end
it "leaves out soft-deleted stores" do
create(:store, name: "Gone").update!(deleted_at: Time.current)
+7 -1
View File
@@ -3,7 +3,6 @@ require "rails_helper"
RSpec.describe Store, type: :model do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:catalog_url) }
it { should validate_presence_of(:store_repository_url) }
it "rejects a catalog url that is not http(s)" do
store = build(:store, catalog_url: "git@example.org:thing.git")
@@ -15,6 +14,13 @@ RSpec.describe Store, type: :model do
expect(build(:store, store_repository_url: "not a url")).not_to be_valid
end
# A store without a repository is a complete store: the client takes identity
# from the record and everything else from the engine's defaults.
it "accepts a store with no repository at all" do
expect(build(:store, store_repository_url: nil)).to be_valid
expect(build(:store, store_repository_url: "")).to be_valid
end
describe ".ordered" do
it "lists stores by name" do
later = create(:store, name: "Zed Games")