From 9255a11254e451226a5b86e9b3dacdf3631c9be6 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 19 Aug 2026 06:43:09 +0200 Subject: [PATCH] Carry a store's configuration in the registry record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/app/admin/stores.rb | 34 ++++++++--- .../app/controllers/api/stores_controller.rb | 21 ++++--- apps/api/app/models/store.rb | 59 ++++++++++++++++--- apps/api/app/serializers/store_serializer.rb | 4 ++ ..._carry_the_store_config_in_the_registry.rb | 16 +++++ apps/api/db/schema.rb | 3 +- .../controllers/stores_controller_spec.rb | 21 ++++++- apps/api/spec/models/store_spec.rb | 46 +++++++++++++++ 8 files changed, 179 insertions(+), 25 deletions(-) create mode 100644 apps/api/db/migrate/20260819000001_carry_the_store_config_in_the_registry.rb diff --git a/apps/api/app/admin/stores.rb b/apps/api/app/admin/stores.rb index 3e0804f..a931e1a 100644 --- a/apps/api/app/admin/stores.rb +++ b/apps/api/app/admin/stores.rb @@ -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 diff --git a/apps/api/app/controllers/api/stores_controller.rb b/apps/api/app/controllers/api/stores_controller.rb index 5663d22..75eb051 100644 --- a/apps/api/app/controllers/api/stores_controller.rb +++ b/apps/api/app/controllers/api/stores_controller.rb @@ -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 diff --git a/apps/api/app/models/store.rb b/apps/api/app/models/store.rb index 2ac9d5d..1f530ef 100644 --- a/apps/api/app/models/store.rb +++ b/apps/api/app/models/store.rb @@ -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 diff --git a/apps/api/app/serializers/store_serializer.rb b/apps/api/app/serializers/store_serializer.rb index 9023ce8..5933f69 100644 --- a/apps/api/app/serializers/store_serializer.rb +++ b/apps/api/app/serializers/store_serializer.rb @@ -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 diff --git a/apps/api/db/migrate/20260819000001_carry_the_store_config_in_the_registry.rb b/apps/api/db/migrate/20260819000001_carry_the_store_config_in_the_registry.rb new file mode 100644 index 0000000..9321a93 --- /dev/null +++ b/apps/api/db/migrate/20260819000001_carry_the_store_config_in_the_registry.rb @@ -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 diff --git a/apps/api/db/schema.rb b/apps/api/db/schema.rb index 53e7d62..2c04507 100644 --- a/apps/api/db/schema.rb +++ b/apps/api/db/schema.rb @@ -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 diff --git a/apps/api/spec/controllers/stores_controller_spec.rb b/apps/api/spec/controllers/stores_controller_spec.rb index 0af44dd..21a0d7c 100644 --- a/apps/api/spec/controllers/stores_controller_spec.rb +++ b/apps/api/spec/controllers/stores_controller_spec.rb @@ -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) diff --git a/apps/api/spec/models/store_spec.rb b/apps/api/spec/models/store_spec.rb index 38f781b..5b28979 100644 --- a/apps/api/spec/models/store_spec.rb +++ b/apps/api/spec/models/store_spec.rb @@ -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")