diff --git a/apps/api/app/admin/stores.rb b/apps/api/app/admin/stores.rb new file mode 100644 index 0000000..66922fe --- /dev/null +++ b/apps/api/app/admin/stores.rb @@ -0,0 +1,53 @@ +ActiveAdmin.register Store do + permit_params :name, :catalog_url, :store_repository_url + + menu priority: 5, label: "🛒 Stores" + + index do + selectable_column + id_column + column :name + column :catalog_url do |store| + link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener" + end + column :store_repository_url do |store| + link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener" + end + column :updated_at + actions + end + + filter :name + filter :catalog_url + filter :store_repository_url + + show do + attributes_table do + row :id + row :name + row :catalog_url do |store| + link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener" + end + row :store_repository_url do |store| + link_to store.store_repository_url, store.store_repository_url, target: "_blank", rel: "noopener" + end + row :created_at + 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." + end + end + + form do |f| + f.inputs 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" + end + f.actions + end +end diff --git a/apps/api/app/controllers/api/stores_controller.rb b/apps/api/app/controllers/api/stores_controller.rb new file mode 100644 index 0000000..b1b38c1 --- /dev/null +++ b/apps/api/app/controllers/api/stores_controller.rb @@ -0,0 +1,20 @@ +class Api::StoresController < ApiController + resource_description do + short "Stores" + end + + 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. + 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" + end + def index + render json: StoreService.new.index + end +end diff --git a/apps/api/app/models/store.rb b/apps/api/app/models/store.rb new file mode 100644 index 0000000..3f207a4 --- /dev/null +++ b/apps/api/app/models/store.rb @@ -0,0 +1,21 @@ +# A store a client can install from: a WarpEngine catalog, and the repository +# holding the 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. +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" } + + default_scope { where(deleted_at: nil) } + + scope :ordered, -> { order(:name) } + + def self.ransackable_attributes(auth_object = nil) + %w[id name catalog_url store_repository_url created_at updated_at] + end +end diff --git a/apps/api/app/serializers/store_serializer.rb b/apps/api/app/serializers/store_serializer.rb new file mode 100644 index 0000000..1d95252 --- /dev/null +++ b/apps/api/app/serializers/store_serializer.rb @@ -0,0 +1,9 @@ +class StoreSerializer < Blueprinter::Base + include WarpEngine::TimestampFields + + field :name + # 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 } +end diff --git a/apps/api/app/services/store_service.rb b/apps/api/app/services/store_service.rb new file mode 100644 index 0000000..30a498d --- /dev/null +++ b/apps/api/app/services/store_service.rb @@ -0,0 +1,5 @@ +class StoreService + def index + StoreSerializer.render_as_hash(Store.ordered) + end +end diff --git a/apps/api/config/routes.rb b/apps/api/config/routes.rb index a28fed6..9e21b35 100644 --- a/apps/api/config/routes.rb +++ b/apps/api/config/routes.rb @@ -7,6 +7,7 @@ Rails.application.routes.draw do get "swagger", to: "swagger#index" get "events", to: "events#index" get "members", to: "members#index" + get "stores", to: "stores#index" get "wiki/pages", to: "wiki#index" get "rss/blog", to: "rss#blog" get "rss/releases", to: "rss#releases" diff --git a/apps/api/db/migrate/20260818000001_create_stores.rb b/apps/api/db/migrate/20260818000001_create_stores.rb new file mode 100644 index 0000000..34a66dc --- /dev/null +++ b/apps/api/db/migrate/20260818000001_create_stores.rb @@ -0,0 +1,19 @@ +class CreateStores < ActiveRecord::Migration[8.1] + def change + create_table :stores, id: { type: :bigint, unsigned: true }, + charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", + if_not_exists: true do |t| + # A store is a WarpEngine catalog plus the repository that configures a + # client for it. The registry lives here rather than in the engine: the + # engine serves one catalog and knows nothing about who ships stores for it. + t.string :name, null: false + t.string :catalog_url, null: false + t.string :store_repository_url, null: false + t.datetime :deleted_at, precision: 3 + t.timestamps precision: 3 + + t.index :deleted_at, name: "idx_stores_deleted_at" + t.index :name, name: "idx_stores_name" + end + end +end diff --git a/apps/api/db/schema.rb b/apps/api/db/schema.rb index 505e7bd..1b4b1a9 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_06_000002) do +ActiveRecord::Schema[8.1].define(version: 2026_08_18_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 @@ -281,6 +281,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do t.index ["owner_type", "owner_id"], name: "idx_softwares_owner" end + 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.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.datetime "updated_at", precision: 3, null: false + t.index ["deleted_at"], name: "idx_stores_deleted_at" + t.index ["name"], name: "idx_stores_name" + end + add_foreign_key "admin_users", "members" add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade diff --git a/apps/api/db/seeds.rb b/apps/api/db/seeds.rb index 1b58a37..c2df4ef 100644 --- a/apps/api/db/seeds.rb +++ b/apps/api/db/seeds.rb @@ -15,3 +15,14 @@ end m.avatar_filename = attrs[:avatar_filename] end end + +# The store registry the graphical desktop client reads. Our own catalog is the +# first record; anyone running this site would add their own the same way, from +# the admin panel or here. +Store.find_or_create_by!(name: "Teletype Games") do |store| + store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org") + store.store_repository_url = ENV.fetch( + "STORE_REPOSITORY_URL", + "https://git.teletypegames.org/stores/ttg-desktop-store" + ) +end diff --git a/apps/api/spec/controllers/stores_controller_spec.rb b/apps/api/spec/controllers/stores_controller_spec.rb new file mode 100644 index 0000000..21bf238 --- /dev/null +++ b/apps/api/spec/controllers/stores_controller_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.describe Api::StoresController, type: :request do + describe "GET /api/stores" do + it "lists stores without a login, in name order" do + create(:store, name: "Zed Games", catalog_url: "https://zed.example") + create(:store, name: "Apex Games", catalog_url: "https://apex.example") + + get "/api/stores" + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"]) + end + + it "answers with the fields a client needs, camelCased" do + create(:store) + + get "/api/stores" + + store = JSON.parse(response.body).first + expect(store).to include("name", "catalogUrl", "storeRepositoryUrl") + expect(store["catalogUrl"]).to eq("https://teletypegames.org") + expect(store["storeRepositoryUrl"]).to end_with("/stores/ttg-desktop-store") + end + + it "leaves out soft-deleted stores" do + create(:store, name: "Gone").update!(deleted_at: Time.current) + + get "/api/stores" + + expect(JSON.parse(response.body)).to be_empty + end + end +end diff --git a/apps/api/spec/factories/stores.rb b/apps/api/spec/factories/stores.rb new file mode 100644 index 0000000..c13046a --- /dev/null +++ b/apps/api/spec/factories/stores.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :store do + name { "Teletype Games" } + catalog_url { "https://teletypegames.org" } + store_repository_url { "https://git.teletypegames.org/stores/ttg-desktop-store" } + end +end diff --git a/apps/api/spec/models/store_spec.rb b/apps/api/spec/models/store_spec.rb new file mode 100644 index 0000000..f3bc387 --- /dev/null +++ b/apps/api/spec/models/store_spec.rb @@ -0,0 +1,33 @@ +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") + expect(store).not_to be_valid + expect(store.errors[:catalog_url]).to include("must be an http(s) URL") + end + + it "rejects a repository url that is not http(s)" do + expect(build(:store, store_repository_url: "not a url")).not_to be_valid + end + + describe ".ordered" do + it "lists stores by name" do + later = create(:store, name: "Zed Games") + first = create(:store, name: "Apex Games") + + expect(Store.ordered).to eq([first, later]) + end + end + + it "hides soft-deleted stores" do + store = create(:store) + store.update!(deleted_at: Time.current) + + expect(Store.all).to be_empty + end +end