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>
This commit is contained in:
2026-08-19 06:43:09 +02:00
co-authored by Claude Opus 5
parent 581d3c4371
commit 9255a11254
8 changed files with 179 additions and 25 deletions
+25 -9
View File
@@ -1,5 +1,5 @@
ActiveAdmin.register Store do
permit_params :name, :catalog_url, :store_repository_url
permit_params :name, :catalog_url, :store_repository_url, :config_json
menu priority: 5, label: "🛒 Stores"
@@ -13,6 +13,9 @@ ActiveAdmin.register Store do
column :store_repository_url do |store|
link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener"
end
column :config do |store|
store.config.blank? ? status_tag("engine defaults") : status_tag("configured", class: "ok")
end
column :updated_at
actions
end
@@ -31,14 +34,22 @@ ActiveAdmin.register Store do
row :store_repository_url do |store|
link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener"
end
row :config do |store|
if store.config.blank?
"— the client uses the store engine's defaults"
else
pre JSON.pretty_generate(store.config)
end
end
row :created_at
row :updated_at
end
para do
"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."
"Listed by GET /api/stores, which the desktop client reads on first run. The " \
"config above is what the client applies, pointed at the catalog above; leave " \
"it empty and the client uses the store engine's defaults, which need nothing " \
"but the name and the catalog. The client always takes the name, the catalog " \
"and the store's slug from this record, whatever the config says."
end
end
@@ -47,10 +58,15 @@ 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: "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"
hint: "Optional, and only a pointer for a person now: where this store's " \
"own repository is, if it has one. Clients from before the config " \
"field below still fetch a config.json from it"
f.input :config_json, as: :text, input_html: { rows: 20, style: "font-family: monospace" },
label: "Config (JSON)",
hint: "Optional. The store's own configuration — platforms, release " \
"statuses, where games land — in the shape a store's config.json " \
"had. Leave it empty and the client uses the store engine's " \
"defaults. Invalid JSON is refused with the text kept"
end
f.actions
end
@@ -6,19 +6,26 @@ 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 there is one the repository holding a store's own
configuration. Public on purpose: a client has nobody to log in as.
exist, and how each store behaves. 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.
A record needs a name and a catalog URL. Both other fields are optional and answer
`null` when unset. `config` is the store's own configuration, in the shape a
store's `config.json` had a client applies it and takes the name, the catalog and
the store's slug from this record regardless. With no `config` a client uses the
store engine's built-in defaults, so neither a config nor a repository has to exist
for a store to be installable.
`storeRepositoryUrl` is a pointer to the store's own repository where it has one.
Clients released before `config` existed fetch a `config.json` from it instead.
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, nil],
desc: "Repository holding the store's config.json, or null when it has none"
desc: "The store's own repository, or null when it has none"
property :config, [Hash, nil],
desc: "The store's configuration, or null when it uses the engine's defaults"
end
def index
render json: StoreService.new.index
+52 -7
View File
@@ -1,15 +1,22 @@
# A store a client can install from: a WarpEngine catalog, and — optionally — a
# repository holding that store's own configuration.
# A store a client can install from: a WarpEngine catalog, and — optionally — the
# configuration that says how that store behaves.
#
# 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.
# Both extras are optional, because a store needs neither. 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.
#
# `config` is that store's own configuration: which platforms it offers, which release
# statuses it shows, where its games land. It used to be a `config.json` in a
# repository the client fetched; carrying it here makes this record the one source of
# truth and removes a second thing that had to exist and stay reachable.
#
# `store_repository_url` is now only a pointer for a person — where the store's own
# repository is, when it has one. A client that predates `config` still reads a
# `config.json` from it, which is why it stays.
class Store < ApplicationRecord
URL = %r{\Ahttps?://\S+\z}
@@ -17,6 +24,8 @@ class Store < ApplicationRecord
validates :catalog_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
validate :config_must_be_an_object
validate :config_json_must_parse
default_scope { where(deleted_at: nil) }
@@ -25,4 +34,40 @@ class Store < ApplicationRecord
def self.ransackable_attributes(auth_object = nil)
%w[id name catalog_url store_repository_url created_at updated_at]
end
# The config as JSON text, which is the only form an admin form can edit. The column
# still holds real JSON — this is a view of it, not a second copy.
def config_json
return @config_json if defined?(@config_json) && !@config_json.nil?
config.blank? ? "" : JSON.pretty_generate(config)
end
def config_json=(text)
@config_json = text
@config_json_error = nil
if text.blank?
self.config = nil
return
end
self.config = JSON.parse(text)
rescue JSON::ParserError => e
# Kept rather than raised: the form has to come back with the text the person
# typed and a message, not a 500.
@config_json_error = e.message
end
private
def config_must_be_an_object
return if config.nil? || config.is_a?(Hash)
errors.add(:config, "must be a JSON object")
end
def config_json_must_parse
return if @config_json_error.blank?
errors.add(:config, "is not valid JSON: #{@config_json_error}")
end
end
@@ -8,4 +8,8 @@ class StoreSerializer < Blueprinter::Base
# 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 }
# The store's own configuration, in the shape a store's `config.json` had — the
# client applies it exactly as it applied that file. Null when the store configures
# nothing, and then the client uses the engine's defaults.
field(:config) { |store| store.config.presence }
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
+2 -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_18_120000) do
ActiveRecord::Schema[8.1].define(version: 2026_08_19_000001) 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
@@ -283,6 +283,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_18_120000) do
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.json "config"
t.datetime "created_at", precision: 3, null: false
t.datetime "deleted_at", precision: 3
t.string "name", null: false
@@ -19,11 +19,30 @@ RSpec.describe Api::StoresController, type: :request do
get "/api/stores"
store = JSON.parse(response.body).first
expect(store).to include("name", "catalogUrl", "storeRepositoryUrl")
expect(store).to include("name", "catalogUrl", "storeRepositoryUrl", "config")
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
expect(store["storeRepositoryUrl"]).to end_with("/stores/ttg-desktop-store")
end
# The config is what the client applies instead of fetching a repository's
# config.json, so it has to come back as an object rather than as JSON text.
it "carries the store's own config as an object" do
create(:store, config: { "paths" => { "subfolder" => "teletypegames" } })
get "/api/stores"
config = JSON.parse(response.body).first["config"]
expect(config).to eq("paths" => { "subfolder" => "teletypegames" })
end
it "answers null for a store that configures nothing" do
create(:store, config: nil)
get "/api/stores"
expect(JSON.parse(response.body).first["config"]).to be_nil
end
it "answers null for a store with no repository" do
create(:store, store_repository_url: nil)
+46
View File
@@ -21,6 +21,52 @@ RSpec.describe Store, type: :model do
expect(build(:store, store_repository_url: "")).to be_valid
end
describe "config" do
it "accepts a store that configures nothing" do
expect(build(:store, config: nil)).to be_valid
end
it "rejects a config that is not a JSON object" do
store = build(:store, config: ["not", "an", "object"])
expect(store).not_to be_valid
expect(store.errors[:config]).to include("must be a JSON object")
end
# The admin form edits JSON text; the column holds real JSON. These two accessors
# are the only bridge between them, so both directions are worth pinning.
it "parses config_json into the column" do
store = build(:store, config_json: '{"paths": {"subfolder": "teletypegames"}}')
expect(store).to be_valid
expect(store.config).to eq("paths" => { "subfolder" => "teletypegames" })
end
it "renders the column back as pretty JSON text" do
store = build(:store, config: { "paths" => { "subfolder" => "teletypegames" } })
expect(JSON.parse(store.config_json)).to eq(store.config)
expect(store.config_json).to include("\n")
end
it "clears the config when the text is emptied" do
store = build(:store, config: { "paths" => {} })
store.config_json = ""
expect(store).to be_valid
expect(store.config).to be_nil
end
# Refused with the text kept, rather than raising: the form has to come back with
# what the person typed and a message they can act on.
it "refuses invalid JSON and keeps the text" do
store = build(:store, config_json: '{"paths": ')
expect(store).not_to be_valid
expect(store.errors[:config].first).to start_with("is not valid JSON")
expect(store.config_json).to eq('{"paths": ')
end
end
describe ".ordered" do
it "lists stores by name" do
later = create(:store, name: "Zed Games")