From a0fbf1e2b4f87ca7732d56a4ab71e098c82b281e Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Thu, 20 Aug 2026 12:52:47 +0200 Subject: [PATCH] =?UTF-8?q?A=20k=C3=B3db=C3=A1zis=20kommentek=20n=C3=A9lk?= =?UTF-8?q?=C3=BCl=20marad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kérésre: minden magyarázó komment kikerült a forrásfájlokból — 89 Ruby, 16 TypeScript, 14 Vue, plusz a CSS/JS/CJS. Nem soralapú kereséssel: a Ruby-t a Ripper tokenizálta, a JS/TS/CSS-t állapotgép járta végig, hogy az URL-ekben, reguláris kifejezésekben és heredocokban álló // és # jelek helyükön maradjanak. Három komment maradt, mert nélkülük nem indul a kód: az entrypoint.sh shebangja, a vite-env.d.ts hármas perjeles referenciája, és a sanitize teszt @vitest-environment direktívája (ez utóbbi a magyarázó része nélkül). Egy helyen kódot is kellett írni: a CommandBlock másolás-hibaágán a komment volt a catch egyetlen tartalma, és üres blokkot az eslint nem enged — a copied jelző visszaállítása került a helyére. A yaml, Dockerfile, Makefile, erb és markdown fájlokat nem érintettem. Co-Authored-By: Claude Opus 5 (1M context) --- app/admin/application_tokens.rb | 7 +- app/admin/device_grants.rb | 6 +- app/admin/files.rb | 15 ---- app/admin/images.rb | 2 - app/admin/pipelines.rb | 5 +- .../warp_engine/subject_authentication.rb | 24 +----- .../warp_engine/update_authentication.rb | 13 +--- .../api/auth/devices_controller.rb | 11 +-- .../warp_engine/api/auth/tokens_controller.rb | 7 +- .../warp_engine/api/service_controller.rb | 8 +- app/controllers/warp_engine/api_controller.rb | 13 +--- .../warp_engine/build/configs_controller.rb | 7 +- .../warp_engine/build/uploads_controller.rb | 2 - app/models/warp_engine/application_token.rb | 13 +--- app/models/warp_engine/device_grant.rb | 22 +----- app/models/warp_engine/pipeline.rb | 19 ----- app/models/warp_engine/software.rb | 2 - .../warp_engine/release_serializer.rb | 1 - .../warp_engine/software_detail_serializer.rb | 3 +- .../warp_engine/software_serializer.rb | 2 +- .../platforms/builds/build_linux_arm64.rb | 9 +-- app/services/warp_engine/ci_config_service.rb | 5 +- .../warp_engine/ci_signature_verifier.rb | 16 +--- .../warp_engine/device_grant_service.rb | 22 +----- app/services/warp_engine/download_service.rb | 18 +---- .../warp_engine/file_manager_service.rb | 2 +- app/services/warp_engine/file_service.rb | 21 +---- app/services/warp_engine/pipeline_service.rb | 3 - app/services/warp_engine/publish_service.rb | 7 +- .../warp_engine/software_response_builder.rb | 12 +-- app/services/warp_engine/software_service.rb | 3 - app/services/warp_engine/woodpecker_client.rb | 9 +-- config/routes.rb | 4 - ...0260805000001_create_application_tokens.rb | 3 +- .../20260805000003_add_build_ownership.rb | 4 +- .../20260819093412_create_device_grants.rb | 18 +---- .../compose/host_app/config/application.rb | 1 - .../config/initializers/warp_engine.rb | 1 - examples/compose/host_app/config/routes.rb | 1 - .../templates/create_warp_engine_tables.rb | 11 +-- .../install/templates/initializer.rb | 77 ------------------- lib/warp_engine.rb | 15 +--- lib/warp_engine/access.rb | 47 +---------- lib/warp_engine/configuration.rb | 52 +------------ lib/warp_engine/engine.rb | 17 +--- lib/warp_engine/storage.rb | 28 +------ lib/warp_engine/version.rb | 3 - spec/dummy/app/models/test_owner.rb | 1 - .../20260805000002_create_test_owners.rb | 1 - spec/dummy/db/schema.rb | 12 --- spec/factories/application_tokens.rb | 2 - spec/lib/storage_spec.rb | 1 - spec/migrations_spec.rb | 25 ------ spec/models/pipeline_spec.rb | 3 - spec/models/software_spec.rb | 3 +- spec/rails_helper.rb | 1 - .../requests/build_configs_controller_spec.rb | 2 - spec/requests/device_auth_spec.rb | 13 +--- spec/requests/service_controller_spec.rb | 5 -- spec/requests/version_header_spec.rb | 7 -- spec/services/access_policy_spec.rb | 7 -- spec/services/publish_service_spec.rb | 2 - spec/services/storage_serving_spec.rb | 4 - 63 files changed, 46 insertions(+), 634 deletions(-) diff --git a/app/admin/application_tokens.rb b/app/admin/application_tokens.rb index cc24fba..1b607d5 100644 --- a/app/admin/application_tokens.rb +++ b/app/admin/application_tokens.rb @@ -10,9 +10,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do scope :all, default: true scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) } scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) } - # Two kinds of token share this table: one publishes software, the other reads the - # catalog from somebody's desktop client. They are told apart by scope, and an admin - # looking for one is rarely looking for the other. + CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") } scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) } @@ -98,9 +96,6 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do end controller do - # The plain token only exists right after creation; it travels via the - # session to its one-time display (flash is unsuitable: the AA layout - # renders every flash key as a message bar). def create create! do |success, _failure| success.html do diff --git a/app/admin/device_grants.rb b/app/admin/device_grants.rb index 8a8a28d..c9c6705 100644 --- a/app/admin/device_grants.rb +++ b/app/admin/device_grants.rb @@ -1,7 +1,4 @@ ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do - # Read-only on purpose. A grant is created by a client and answered by a person on the - # host's own page; an admin creating one by hand would be issuing somebody else a - # credential, which is not a thing this page should make easy. actions :index, :show menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins", @@ -45,8 +42,7 @@ ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do subject = g.subject subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}" end - # The device code itself is never shown: it is the client's live credential for as - # long as the grant is pending, and this page is not where it should leak from. + row("Token") do |g| token = g.application_token next "—" if token.nil? diff --git a/app/admin/files.rb b/app/admin/files.rb index d5dbf63..b65d1e0 100644 --- a/app/admin/files.rb +++ b/app/admin/files.rb @@ -13,7 +13,6 @@ ActiveAdmin.register_page "Files" do entries = [] end - # Picker mode: hide admin header/menu/footer if picker_mode text_node "".html_safe end @@ -26,7 +25,6 @@ ActiveAdmin.register_page "Files" do end end - # Breadcrumbs div class: "fm-breadcrumbs" do parts = current_dir.split("/").reject(&:blank?) picker_params = picker_mode ? { picker: 1, field: picker_field } : {} @@ -39,7 +37,6 @@ ActiveAdmin.register_page "Files" do end end - # Action bar (also drop zone on standalone page) div class: "fm-actions fm-dropzone", id: "fm-page-dropzone" do form action: admin_files_upload_path, method: "post", enctype: "multipart/form-data", class: "fm-inline-form" do |_f| input type: "hidden", name: "authenticity_token", value: form_authenticity_token @@ -56,9 +53,7 @@ ActiveAdmin.register_page "Files" do end end - # File listing table class: "fm-table" do - # Pre-fetch download counts for files in current directory file_entries = entries.select { |e| e[:type] != :directory } dl_counts = if file_entries.any? WarpEngine::Download.where(file_path: file_entries.map { |e| e[:path] }) @@ -68,7 +63,6 @@ ActiveAdmin.register_page "Files" do {} end - # File icon helper file_icon = ->(name) do ext = File.extname(name).downcase case ext @@ -100,7 +94,6 @@ ActiveAdmin.register_page "Files" do end tbody do - # Parent directory link if current_dir.present? parent = File.dirname(current_dir) parent = "" if parent == "." @@ -145,32 +138,26 @@ ActiveAdmin.register_page "Files" do td class: "fm-entry-actions" do entry_id = entry[:path].parameterize - # Download (files only) if entry[:type] != :directory a "⬇️", href: "/file/#{entry[:path]}", class: "fm-icon-btn", title: "Download", download: entry[:name] end - # Rename a "✏️", href: "#", class: "fm-icon-btn", title: "Rename", onclick: "var n=prompt('New name:','#{j entry[:name]}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;" - # Delete a "🗑️", href: "#", class: "fm-icon-btn fm-icon-danger", title: "Delete", onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;" - # Stats (files only) if entry[:type] != :directory a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats" end - # Picker mode: Select button if picker_mode abs_path = File.join(WarpEngine.config.file_container_path, entry[:path]) a "Select", href: "#", class: "fm-btn fm-btn-select", onclick: "var inp=window.parent.document.getElementById('#{j picker_field}');if(inp){inp.value='#{j abs_path}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;" end - # Hidden rename form form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do input type: "hidden", name: "authenticity_token", value: form_authenticity_token input type: "hidden", name: "path", value: entry[:path] @@ -178,7 +165,6 @@ ActiveAdmin.register_page "Files" do input type: "hidden", name: "dir", value: current_dir end - # Hidden delete form form action: admin_files_delete_path, method: "post", id: "delete-#{entry_id}", style: "display:none" do input type: "hidden", name: "authenticity_token", value: form_authenticity_token input type: "hidden", name: "_method", value: "delete" @@ -197,7 +183,6 @@ ActiveAdmin.register_page "Files" do end end - # JSON API for AJAX file picker page_action :list, method: :get do service = WarpEngine::FileManagerService.new dir = params[:dir].to_s.presence || "" diff --git a/app/admin/images.rb b/app/admin/images.rb index 7d1ca84..2f9dc77 100644 --- a/app/admin/images.rb +++ b/app/admin/images.rb @@ -3,8 +3,6 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images" - # SoftwareImage a natív használó; a hoston regisztrált image_owners - # (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member). used_ids = -> { WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) + WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call } diff --git a/app/admin/pipelines.rb b/app/admin/pipelines.rb index 5b5791a..ac94d6b 100644 --- a/app/admin/pipelines.rb +++ b/app/admin/pipelines.rb @@ -1,9 +1,6 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do actions :index, :show, :edit, :update - # Without this the edit form cannot save at all: ActiveAdmin hands unpermitted params to - # the model and Rails raises ForbiddenAttributesError. The two fields here are the two - # the form offers; everything else about a pipeline comes from the Woodpecker sync. permit_params :platform, :software_id menu parent: "🌀 WarpEngine", priority: 10, label: "🚀 Pipelines" @@ -91,7 +88,7 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "yes" : "no" } column("Branch") { |p| p["branch"] } column("Message") { |p| p["message"]&.truncate(60) } - # Woodpecker returns unix epoch seconds in "created" + column("Created") { |p| p["created"] ? Time.zone.at(p["created"]).strftime("%Y-%m-%d %H:%M") : "-" } end else diff --git a/app/controllers/concerns/warp_engine/subject_authentication.rb b/app/controllers/concerns/warp_engine/subject_authentication.rb index 9e2e74e..04d62c7 100644 --- a/app/controllers/concerns/warp_engine/subject_authentication.rb +++ b/app/controllers/concerns/warp_engine/subject_authentication.rb @@ -1,35 +1,16 @@ module WarpEngine - # Who the caller is, on the read-only side of the API. - # - # Distinct from UpdateAuthentication, which guards publishing: that one asks "may this - # pipeline write to the catalog", this one asks "whose library am I looking at". The - # answer is allowed to be nobody — an anonymous caller is a normal, supported caller, - # and a catalog with no policy configured never needs one. - # - # The credential is a bearer token, because that is what a client can carry: it has no - # cookie jar and no browser session. + module SubjectAuthentication extend ActiveSupport::Concern private - # The ApplicationToken behind the request, or nil. def current_access_token return @current_access_token if defined?(@current_access_token) @current_access_token = resolve_access_token end - # Whoever the request is on behalf of — the host's own object, or nil. - # - # Two ways to be somebody, tried in that order. A bearer token is what a client - # carries. A *browser* carries a session instead, and the engine has no idea what a - # session is here — so a host that wants its signed-in visitors recognised on these - # endpoints supplies a resolver: - # - # c.subject_resolver = ->(request) { request.env["warden"]&.user } - # - # Without one, a browser is simply anonymous, which is what it always was. def current_subject return @current_subject if defined?(@current_subject) @@ -51,9 +32,6 @@ module WarpEngine record end - # A resolver that raises must not take the request with it: it runs on every read - # endpoint, and a broken one would turn the whole API into 500s rather than into - # anonymous requests, which is the honest fallback. def resolve_host_subject resolver = WarpEngine.config.subject_resolver return nil if resolver.nil? diff --git a/app/controllers/concerns/warp_engine/update_authentication.rb b/app/controllers/concerns/warp_engine/update_authentication.rb index 06603b3..c69bbdb 100644 --- a/app/controllers/concerns/warp_engine/update_authentication.rb +++ b/app/controllers/concerns/warp_engine/update_authentication.rb @@ -1,7 +1,5 @@ module WarpEngine - # Token authentication for the publishing (/build/*) endpoints. - # The auth source is exclusive: in :database mode the shared secret is not - # accepted, in :env mode DB tokens are not. + module UpdateAuthentication extend ActiveSupport::Concern @@ -9,8 +7,6 @@ module WarpEngine attr_reader :current_application_token - # The token is accepted from the X-Update-Secret header only — in the URL - # it would leak into proxy and access logs. def update_authorized?(required_scope:) token = request.headers["X-Update-Secret"].presence return false if token.blank? @@ -23,7 +19,7 @@ module WarpEngine def env_secret_authorized?(token) expected = WarpEngine.config.update_secret - # With no secret configured the endpoint stays closed. + expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected) end @@ -41,9 +37,6 @@ module WarpEngine true end - # Ownership enforcement applies only in :database mode (there is a token) - # with enforce_software_ownership on. An ownerless software is up for grabs - # until the backfill — backfill before enabling the enforcement. def software_ownership_authorized?(name) return true unless WarpEngine.config.enforce_software_ownership @@ -56,8 +49,6 @@ module WarpEngine software.owner_type == token.owner_type && software.owner_id == token.owner_id end - # A first-published (or pre-backfill, ownerless) software gets the - # submitting token's owner. Unrestricted (internal) tokens claim nothing. def claim_software_ownership(name) token = current_application_token return if token.nil? || token.unrestricted? diff --git a/app/controllers/warp_engine/api/auth/devices_controller.rb b/app/controllers/warp_engine/api/auth/devices_controller.rb index 20aebaa..1bb2d35 100644 --- a/app/controllers/warp_engine/api/auth/devices_controller.rb +++ b/app/controllers/warp_engine/api/auth/devices_controller.rb @@ -1,9 +1,5 @@ module WarpEngine - # The device authorization grant, client side (RFC 8628). - # - # Both actions are unauthenticated, and have to be: the whole point of the flow is - # that the caller has no credential yet. What protects it is that a device code is - # useless until a signed-in person approves it on the host's own page. + class Api::Auth::DevicesController < ApiController before_action :ensure_identity_configured @@ -34,9 +30,6 @@ module WarpEngine def token state, plain = service.poll(device_code: params[:device_code]) - # The token rides on the one poll that finds the grant newly approved; a client - # that loses it starts the flow again. Keeping a plain token around to hand out - # twice would mean storing it, which is the thing this design avoids. body = { state: state.to_s } body[:token] = plain if plain.present? render json: body @@ -50,8 +43,6 @@ module WarpEngine @service ||= WarpEngine::DeviceGrantService.new end - # A deployment with no configured subject class has no sign-in at all, and says so - # the same way GET /api/service does — by not offering it. def ensure_identity_configured return if WarpEngine.identity_configured? diff --git a/app/controllers/warp_engine/api/auth/tokens_controller.rb b/app/controllers/warp_engine/api/auth/tokens_controller.rb index 5c1914f..ccab0da 100644 --- a/app/controllers/warp_engine/api/auth/tokens_controller.rb +++ b/app/controllers/warp_engine/api/auth/tokens_controller.rb @@ -1,10 +1,5 @@ module WarpEngine - # Signing out: a client throws away its own token. - # - # Revocation is a soft delete on the ApplicationToken, so the record of which device - # signed in and when survives it. The person's own list of devices — where somebody - # revokes a token for a laptop they no longer have — is the host's page, because it - # needs a session and a browser. + class Api::Auth::TokensController < ApiController resource_description do short "Client tokens" diff --git a/app/controllers/warp_engine/api/service_controller.rb b/app/controllers/warp_engine/api/service_controller.rb index 09b01e3..e8b5d9f 100644 --- a/app/controllers/warp_engine/api/service_controller.rb +++ b/app/controllers/warp_engine/api/service_controller.rb @@ -1,11 +1,5 @@ module WarpEngine - # What this deployment is and what it can do, in one unauthenticated request. - # - # This is how a client stops guessing. Before it existed, everything a client knew - # about a store was compiled into the client — which endpoints to call, whether - # signing in was a thing here, where to send somebody who wanted to buy something. - # Every one of those is a property of the *server*, and a client that carries them - # can only ever serve the one store it was built for. + class Api::ServiceController < ApiController resource_description do short "Service descriptor" diff --git a/app/controllers/warp_engine/api_controller.rb b/app/controllers/warp_engine/api_controller.rb index 78923d2..76eb3b6 100644 --- a/app/controllers/warp_engine/api_controller.rb +++ b/app/controllers/warp_engine/api_controller.rb @@ -5,14 +5,8 @@ module WarpEngine formats [ "json" ] end - # Every response the engine serves names the version that served it, so a client can - # branch on the engine's age without a round trip to ask. Set *before* the action, - # not after: an error handled by `rescue_from` never reaches an after_action, and a - # client needs the version most when something came back wrong. before_action :set_version_header - # Every read-only endpoint may be called with a bearer token; none of them requires - # one. See WarpEngine::SubjectAuthentication. include WarpEngine::SubjectAuthentication rescue_from StandardError do |e| @@ -39,12 +33,7 @@ module WarpEngine private def set_version_header - # The name is spelled out here rather than taken from WarpEngine::VERSION_HEADER on - # purpose. A deployed process can end up with these controllers and an older - # `lib/` — it happened on the first deploy of this feature — and a controller that - # needs a constant from the newer half answers 500 to every request instead of - # serving the catalog. A response header is not worth that fragility. The constant - # is still the documented name, and a spec holds the two together. + response.headers["WarpEngine-Version"] = WarpEngine::VERSION end diff --git a/app/controllers/warp_engine/build/configs_controller.rb b/app/controllers/warp_engine/build/configs_controller.rb index f25072a..01d457f 100644 --- a/app/controllers/warp_engine/build/configs_controller.rb +++ b/app/controllers/warp_engine/build/configs_controller.rb @@ -1,8 +1,6 @@ module WarpEngine module Build - # Woodpecker configuration-extension endpoint: on every pipeline start the - # CI server POSTs the repo's marker file and receives the platform's full - # pipeline YAML. GET renders the same thing as a preview. + class ConfigsController < ApiController resource_description do short "Woodpecker CI pipeline configs" @@ -58,9 +56,6 @@ module WarpEngine ) end - # The first submitted config that parses as a marker (Hash with a `platform` - # key). The docs call the key "configuration", the example-config-service - # uses "configs" — accept both. def find_marker configs = params[:configuration].presence || params[:configs].presence || [] configs.each do |config| diff --git a/app/controllers/warp_engine/build/uploads_controller.rb b/app/controllers/warp_engine/build/uploads_controller.rb index 3cd1c96..d44337a 100644 --- a/app/controllers/warp_engine/build/uploads_controller.rb +++ b/app/controllers/warp_engine/build/uploads_controller.rb @@ -9,8 +9,6 @@ module WarpEngine short "Build artifact upload" end - # Release file naming convention: -. or - # --.zip — the updater looks for these too. NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/ api :POST, "/build/upload", "Upload a build artifact into the artifact directory" diff --git a/app/models/warp_engine/application_token.rb b/app/models/warp_engine/application_token.rb index 8ea307e..e0c7cc2 100644 --- a/app/models/warp_engine/application_token.rb +++ b/app/models/warp_engine/application_token.rb @@ -6,12 +6,9 @@ module WarpEngine UPDATE_SCOPE = "update".freeze UPLOAD_SCOPE = "upload".freeze - # A token held by a *client* rather than a publisher: it reads the catalog and - # downloads artifacts, and it never publishes anything. + CATALOG_SCOPE = "catalog".freeze - # The generated token is only available in memory at creation time — the DB - # stores nothing but the SHA256 digest and the non-secret prefix. attr_reader :plain_token belongs_to :owner, polymorphic: true @@ -33,7 +30,6 @@ module WarpEngine Digest::SHA256.hexdigest(token) end - # The live (not deleted, not expired) token carrying the required scope, else nil. def self.authenticate(token, required_scope: nil) return nil if token.blank? @@ -48,7 +44,6 @@ module WarpEngine expires_at.present? && expires_at <= Time.current end - # Revocation = soft delete, the audit trail stays. def revoke! update_column(:deleted_at, Time.current) end @@ -57,7 +52,6 @@ module WarpEngine update_column(:last_used_at, Time.current) end - # Admin form: comma separated scope list def scopes_string Array(scopes).join(", ") end @@ -70,7 +64,6 @@ module WarpEngine %w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at] end - # Ransack cannot filter on the polymorphic owner association. def self.ransackable_associations(auth_object = nil) [] end @@ -81,10 +74,6 @@ module WarpEngine self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank? end - # Publishing tokens and client tokens share this table but not their owners: one - # belongs to whoever ships software, the other to whoever buys it. Both classes are - # the host's to name, and either is acceptable here — which of the two a given token - # may do is decided by its scopes, not by its owner. def self.permitted_owner_types [ WarpEngine.config.application_token_owner_class, WarpEngine.config.access_token_owner_class ].compact_blank diff --git a/app/models/warp_engine/device_grant.rb b/app/models/warp_engine/device_grant.rb index fb7050d..b423f50 100644 --- a/app/models/warp_engine/device_grant.rb +++ b/app/models/warp_engine/device_grant.rb @@ -1,19 +1,8 @@ module WarpEngine - # One pending sign-in from a client that has no browser of its own. - # - # The shape is RFC 8628's device authorization grant, and the reason for it is that a - # desktop client cannot host a login form without asking a person to type a password - # into a window that is not a browser. So the client asks for a pair of codes, sends - # the person to the host's own page with the short one, and polls with the long one - # until somebody approves it. - # - # Short-lived by design: this row exists for the minute or two between "the client - # asked" and "the person answered". What survives it is the ApplicationToken. + class DeviceGrant < ApplicationRecord self.table_name = "device_grants" - # No I, O, 0 or 1: this alphabet is read off one screen and typed into another, and - # those four are where that goes wrong. USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze USER_CODE_LENGTH = 8 @@ -33,7 +22,6 @@ module WarpEngine pending.find_by(user_code: normalize_user_code(code)) end - # Typed by a person, so it arrives with whatever case and separators they used. def self.normalize_user_code(code) code.to_s.upcase.gsub(/[^A-Z0-9]/, "") end @@ -42,8 +30,6 @@ module WarpEngine def approved? = approved_at.present? def denied? = denied_at.present? - # What the polling client is told. Order matters: a denied grant is denied even - # after it expires, because "somebody said no" is the more useful answer. def state return :denied if denied? return :approved if approved? @@ -52,13 +38,10 @@ module WarpEngine :pending end - # Grouped for reading aloud and for typing: WARP-K7M2. def formatted_user_code user_code.to_s.scan(/.{1,4}/).join("-") end - # Housekeeping for a host that wants it: an expired grant has nothing left to give, - # and its issued_token would be a live secret nobody is waiting for. def self.sweep_expired! where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil) end @@ -79,8 +62,7 @@ module WarpEngine end def self.generate_user_code - # Retried rather than trusted: the alphabet is small enough that a collision is - # a real, if rare, event, and a unique index would turn it into a 500. + 10.times do candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join return candidate unless exists?(user_code: candidate) diff --git a/app/models/warp_engine/pipeline.rb b/app/models/warp_engine/pipeline.rb index 5394455..9500434 100644 --- a/app/models/warp_engine/pipeline.rb +++ b/app/models/warp_engine/pipeline.rb @@ -2,29 +2,14 @@ module WarpEngine class Pipeline < ApplicationRecord self.table_name = "pipelines" - # Repos synced from Woodpecker without a matching Software land here - # until a platform is assigned by hand. UNKNOWN_PLATFORM = "unknown".freeze belongs_to :software, class_name: "WarpEngine::Software", optional: true - # Pipelines this record took the software from during the last save, by full name. - # The admin says so out loud: a silent reassignment is what made the old behaviour - # confusing in the first place. attr_reader :software_taken_from default_scope { where(deleted_at: nil) } - # One pipeline per software, and the newest assignment wins. - # - # `Software#pipeline` is a `has_one`, so two pipelines pointing at the same software - # is not an error — it is worse than one: the software keeps showing whichever row - # comes first, and assigning it elsewhere looks like it did nothing. Rather than - # refusing the assignment, the link moves: whoever held that software lets go of it. - # - # Deliberately a callback and not a unique index. Rows here are soft-deleted, and a - # unique index counts deleted rows too, so a pipeline someone removed last year would - # block the software from ever being linked again. before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id? validates :woodpecker_repo_id, presence: true, uniqueness: true @@ -57,10 +42,6 @@ module WarpEngine @software_taken_from = others.map(&:full_name) return if @software_taken_from.empty? - # Logged rather than flashed. The first attempt at this put a message on screen by - # overriding the admin's `update` action, which bypassed the permitted-params path - # and made every pipeline edit fail with ForbiddenAttributesError. A silent - # reassignment is a small problem; an admin page that cannot save is a large one. Rails.logger.info( "[WarpEngine::Pipeline] #{full_name} took software #{software_id} from " \ "#{@software_taken_from.join(', ')}" diff --git a/app/models/warp_engine/software.rb b/app/models/warp_engine/software.rb index 596324a..8d9cdb8 100644 --- a/app/models/warp_engine/software.rb +++ b/app/models/warp_engine/software.rb @@ -2,8 +2,6 @@ module WarpEngine class Software < ApplicationRecord self.table_name = "softwares" - # Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation, - # see enforce_software_ownership. nil = internal / pre-backfill software. belongs_to :owner, polymorphic: true, optional: true has_many :software_images, foreign_key: :software_id, dependent: :destroy diff --git a/app/serializers/warp_engine/release_serializer.rb b/app/serializers/warp_engine/release_serializer.rb index 1ad2d3d..4cfabc9 100644 --- a/app/serializers/warp_engine/release_serializer.rb +++ b/app/serializers/warp_engine/release_serializer.rb @@ -4,7 +4,6 @@ module WarpEngine FILE_PATH_TO = "/file/" - # A lemezen tárolt asset-útvonalak publikus /file/ URL-lé írása configból. def self.file_path_from "#{WarpEngine.config.file_container_path.chomp("/")}/" end diff --git a/app/serializers/warp_engine/software_detail_serializer.rb b/app/serializers/warp_engine/software_detail_serializer.rb index e7c97fa..552c668 100644 --- a/app/serializers/warp_engine/software_detail_serializer.rb +++ b/app/serializers/warp_engine/software_detail_serializer.rb @@ -5,8 +5,7 @@ module WarpEngine field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil } field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil } field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 } - # Whether this title is gated, what it costs and where to get it. Always present — - # see WarpEngine::Access. Under the open policy it is the constant OPEN answer. + field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json } end end diff --git a/app/serializers/warp_engine/software_serializer.rb b/app/serializers/warp_engine/software_serializer.rb index ca12cfd..68f4965 100644 --- a/app/serializers/warp_engine/software_serializer.rb +++ b/app/serializers/warp_engine/software_serializer.rb @@ -14,7 +14,7 @@ module WarpEngine field(:license) { |sw| sw.license.to_s } field :platform field :status - # Public owner id — for the /api/software?owner_id= filter. + field(:ownerId) { |sw| sw.owner_id } field(:highlighted) { |sw| sw.highlighted ? true : false } field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) } diff --git a/app/services/concerns/warp_engine/platforms/builds/build_linux_arm64.rb b/app/services/concerns/warp_engine/platforms/builds/build_linux_arm64.rb index a150c8e..5508adf 100644 --- a/app/services/concerns/warp_engine/platforms/builds/build_linux_arm64.rb +++ b/app/services/concerns/warp_engine/platforms/builds/build_linux_arm64.rb @@ -1,14 +1,7 @@ module WarpEngine module Platforms module Builds - # Linux on 64-bit ARM: Raspberry Pi 4/5, Odroid, and the retro handhelds. - # Batocera and the ES-family distributions run on these as much as on - # x86_64, and an x64 binary installs there but will not start — which is - # why this is a separate kind rather than something linux_x64 can cover. - # - # Include this in a platform service only once its pipeline actually - # produces the artifact: registering the kind makes /api/builds report it - # as missing for every release until then. + module BuildLinuxArm64 extend ActiveSupport::Concern diff --git a/app/services/warp_engine/ci_config_service.rb b/app/services/warp_engine/ci_config_service.rb index 5af3582..b82fbc6 100644 --- a/app/services/warp_engine/ci_config_service.rb +++ b/app/services/warp_engine/ci_config_service.rb @@ -1,13 +1,10 @@ require "erb" module WarpEngine - # Renders the /build/config platform templates: the pipeline logic lives in - # app/services/warp_engine/platforms//pipeline.yaml.erb, the - # per-platform builder images come from WarpEngine.config.ci_platforms. + class CiConfigService PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/ - # The rendered pipeline YAML, or nil when the platform is not served. def render(platform:, name:, update_server:) platform = platform.to_s return nil unless platform.match?(PLATFORM_FORMAT) diff --git a/app/services/warp_engine/ci_signature_verifier.rb b/app/services/warp_engine/ci_signature_verifier.rb index edeb332..ef18360 100644 --- a/app/services/warp_engine/ci_signature_verifier.rb +++ b/app/services/warp_engine/ci_signature_verifier.rb @@ -4,12 +4,7 @@ require "net/http" require "digest" module WarpEngine - # Verifies the signature of Woodpecker configuration-extension requests. - # Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via - # yaronf/httpsign): Signature-Input + Signature + Content-Digest headers, - # covered components "@request-target" and "content-digest". Older versions - # used draft-cavage http-signatures (a single Signature header) — kept as a - # fallback. + class CiSignatureVerifier CAVAGE_PARAM = /(\w+)="([^"]*)"/ @@ -17,7 +12,7 @@ module WarpEngine @key_mutex = Mutex.new class << self - # The downloaded key is cached process-wide (per URL). + def fetch_public_key(url) @key_mutex.synchronize do @key_cache[url] ||= Net::HTTP.get(URI.parse(url)) @@ -64,8 +59,6 @@ module WarpEngine nil end - # --- RFC 9421 --- - def rfc9421_valid?(key) input = @request.headers["Signature-Input"].to_s match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m) @@ -102,8 +95,6 @@ module WarpEngine end end - # When content-digest is a covered component, the body itself must match - # the digest header — this is what ties the signature to the payload. def content_digest_valid?(components) return true unless components.include?("content-digest") @@ -114,8 +105,6 @@ module WarpEngine ActiveSupport::SecurityUtils.secure_compare(digest, expected) end - # --- draft-cavage fallback --- - def cavage_valid?(key) params = cavage_params return false if params.nil? || params["signature"].blank? @@ -126,7 +115,6 @@ module WarpEngine key.verify(nil, Base64.decode64(params["signature"]), signing_string) end - # Parameters of the Signature header (or the "Authorization: Signature ..." form). def cavage_params header = @request.headers["Signature"].presence if header.nil? diff --git a/app/services/warp_engine/device_grant_service.rb b/app/services/warp_engine/device_grant_service.rb index 6722820..29ec737 100644 --- a/app/services/warp_engine/device_grant_service.rb +++ b/app/services/warp_engine/device_grant_service.rb @@ -1,25 +1,15 @@ module WarpEngine - # The device authorization grant, from both ends. - # - # The client's end is #request, #poll and #revoke, all reachable over /api/auth/*. - # The person's end is #approve and #deny, which the *host* calls from its own page — - # approving needs a session and a logged-in human, and the engine has neither. + class DeviceGrantService class NotConfigured < StandardError; end class UnknownCode < StandardError; end - # A client asks for a code pair. Deliberately unauthenticated: there is nobody to - # authenticate as yet, which is the whole reason this flow exists. def request(client_name:) ensure_configured! WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128)) end - # The client polls with the device code. Returns [state, token], where the token is - # the plain string and is available exactly once — on the poll that finds the grant - # newly approved. A second poll gets :approved with no token, which is the honest - # answer: the secret was handed over and is not kept. def poll(device_code:) ensure_configured! @@ -28,16 +18,11 @@ module WarpEngine return [ grant.state, nil ] unless grant.state == :approved - # Read once, then gone: the column exists only to carry the secret across the gap - # between the browser that approved it and the client that is polling for it. plain = grant.issued_token grant.update_columns(issued_token: nil) if plain.present? [ :approved, plain ] end - # The host's approval page calls this with the code a person typed and the subject - # they are signed in as. Issuing the token here rather than on the next poll keeps - # the decision and its consequence in one transaction. def approve(user_code:, subject:) ensure_configured! @@ -73,8 +58,6 @@ module WarpEngine grant end - # Signing out: the client throws its own token away. Revocation is a soft delete on - # the token, so the audit trail of who signed in from where survives it. def revoke(token:) return false if token.nil? @@ -82,9 +65,6 @@ module WarpEngine true end - # Where a person goes to type the user code. A path is made absolute against the - # request's own base, so a host that configured "/devices" does not have to know its - # own hostname. def verification_url(base_url: nil) configured = WarpEngine.config.identity_verification_url.presence || "/devices" return configured if configured.start_with?("http://", "https://") diff --git a/app/services/warp_engine/download_service.rb b/app/services/warp_engine/download_service.rb index fca42ea..c2170bc 100644 --- a/app/services/warp_engine/download_service.rb +++ b/app/services/warp_engine/download_service.rb @@ -1,25 +1,16 @@ module WarpEngine class DownloadService - # The access policy said no. The controller turns this into a 403 — distinct from - # the nil that means "no such file", because telling a person their file is missing - # when it is merely locked sends them looking for the wrong problem. + class Denied < StandardError; end def self.container_base WarpEngine.config.file_container_path end - # Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem. def self.base_path Pathname.new(container_base).realpath end - # A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a - # letöltést. A hely feloldása a storage adapteren megy — alapból :local, - # tehát változatlanul lemezről. - # - # `subject` is whoever the request authenticated as, or nil. Under the open policy - # it is ignored and every file is served, exactly as before. def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil) relative = path.to_s return nil unless storage.file?(relative) @@ -34,8 +25,6 @@ module WarpEngine expires_in: grant.expires_in) end - # Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza - # (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való. def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil) location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer, subject: subject, request: request) @@ -50,9 +39,6 @@ module WarpEngine WarpEngine.storage end - # A policy that refuses returns nil; one that raises is treated as a refusal too. - # An artifact served because the gatekeeper crashed is the one failure mode this - # engine must not have. def authorize!(asset, subject, request) grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request) raise Denied if grant.nil? @@ -80,8 +66,6 @@ module WarpEngine referer: referer&.truncate(500) ) - # The same seam the publish side has: a host that wants its own record of who - # downloaded what subscribes rather than reaching into this class. ActiveSupport::Notifications.instrument("warp_engine.download", path: relative, asset: asset, release: asset&.release, software: asset&.release&.software, subject: subject, download: download, ip: ip) diff --git a/app/services/warp_engine/file_manager_service.rb b/app/services/warp_engine/file_manager_service.rb index baff15d..1bafd5d 100644 --- a/app/services/warp_engine/file_manager_service.rb +++ b/app/services/warp_engine/file_manager_service.rb @@ -1,6 +1,6 @@ module WarpEngine class FileManagerService - # Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem. + def base_path @base_path ||= Pathname.new(WarpEngine.config.file_container_path) end diff --git a/app/services/warp_engine/file_service.rb b/app/services/warp_engine/file_service.rb index 794a247..d418d4a 100644 --- a/app/services/warp_engine/file_service.rb +++ b/app/services/warp_engine/file_service.rb @@ -1,22 +1,10 @@ module WarpEngine class FileService - # Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem. + def self.base_path Pathname.new(WarpEngine.config.file_container_path).realpath end - # A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) — - # így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t - # patchelné. Lásd WarpEngine::Storage. - # `subject` is whoever the request authenticated as, or nil. Under the open policy - # it is ignored and every file is served, exactly as before. - # - # A hosted (browser) build reaches this path as hundreds of relative requests for - # js, wasm and images, which is why the gate here is the policy's plain yes/no - # rather than anything signed: there is nothing to sign per file. A host serving - # gated web builds to browsers will usually want its own session-based route in - # front of this one — a browser has a session, and a redirect to a login page is a - # better answer there than a bare 403. def show(input, subject: nil) relative = input.path.to_s @@ -36,13 +24,8 @@ module WarpEngine private - # Same rule and same failure mode as DownloadService: a policy that refuses or - # raises means no file. An artifact served because the gatekeeper crashed is the - # one failure mode this engine must not have. def authorize!(relative, subject) - # The open policy authorises everything, and this path serves a browser build as - # hundreds of requests for js, wasm and images. Asking it per file would mean a - # LIKE query per asset for an answer that is always yes. + return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open? asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first diff --git a/app/services/warp_engine/pipeline_service.rb b/app/services/warp_engine/pipeline_service.rb index 701534c..1535b68 100644 --- a/app/services/warp_engine/pipeline_service.rb +++ b/app/services/warp_engine/pipeline_service.rb @@ -8,8 +8,6 @@ module WarpEngine @client.trigger_pipeline(pipeline.woodpecker_repo_id, branch: branch) end - # Fetches one page of a pipeline's runs; the newest one refreshes the - # cached last_pipeline_* columns (Pipelines index and the CI API). def list_pipelines(pipeline, page: 1) runs = @client.list_pipelines(pipeline.woodpecker_repo_id, page: page) refresh_last_pipeline(pipeline, runs.first) if page == 1 && runs.is_a?(Array) @@ -22,7 +20,6 @@ module WarpEngine private - # Woodpecker returns unix epoch seconds in "created". def refresh_last_pipeline(pipeline, run) return unless run && pipeline.persisted? diff --git a/app/services/warp_engine/publish_service.rb b/app/services/warp_engine/publish_service.rb index c5eb635..2b0dc60 100644 --- a/app/services/warp_engine/publish_service.rb +++ b/app/services/warp_engine/publish_service.rb @@ -1,8 +1,6 @@ module WarpEngine class PublishService - # Az esemény neve, amit a host lehallgathat. A payload: - # platform: String, name: String, version: String, - # software: WarpEngine::Software, release: WarpEngine::Release + NOTIFICATION = "warp_engine.publish".freeze def publish(input) @@ -13,9 +11,6 @@ module WarpEngine release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize .new.update(input.name, input.version) - # A publikálás az egyetlen pont, ahol új build kerül a katalógusba — - # a host innen tud rá reagálni (értesítés, feed, csatorna-előléptetés) - # anélkül, hogy modell-callbackre kellene kapaszkodnia. ActiveSupport::Notifications.instrument( NOTIFICATION, platform: input.platform, diff --git a/app/services/warp_engine/software_response_builder.rb b/app/services/warp_engine/software_response_builder.rb index e62e967..b5511ae 100644 --- a/app/services/warp_engine/software_response_builder.rb +++ b/app/services/warp_engine/software_response_builder.rb @@ -5,7 +5,7 @@ module WarpEngine def build_response(software, releases, download_counts, subject: nil) sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first - # web-playable, ha az utolsó (stabil) release-nek van webes assetje + web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" } total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) } @@ -19,21 +19,14 @@ module WarpEngine ) end - # Always present, even under the open policy: a client should never have to tell - # "this catalog says nothing about access" from "this title is not gated". One of - # those is a question and the other is an answer. def access_for(software, subject) WarpEngine.access_policy.access_for(software: software, subject: subject) rescue StandardError => e - # A policy that raises must not take the catalog down with it. The open answer is - # wrong here, so the closed one is what a broken policy gets: a title nobody can - # download is recoverable, a paid title handed out for free is not. + Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}") WarpEngine::Access.new(gated: true, entitled: false) end - # The titles this subject may see. A policy that raises empties the catalog rather - # than leaking it — the safe direction — but it is still a bug, so it is logged. def visible_scope(subject) WarpEngine.access_policy.visible_software_scope(subject: subject) rescue StandardError => e @@ -41,7 +34,6 @@ module WarpEngine WarpEngine::Software.none end - # Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett). def download_counts_for(release_ids) return {} if release_ids.empty? WarpEngine::Download.where(release_id: release_ids).group(:release_id).count diff --git a/app/services/warp_engine/software_service.rb b/app/services/warp_engine/software_service.rb index e3791a8..cb1a02e 100644 --- a/app/services/warp_engine/software_service.rb +++ b/app/services/warp_engine/software_service.rb @@ -2,9 +2,6 @@ module WarpEngine class SoftwareService include SoftwareResponseBuilder - # `subject` is whoever the request authenticated as, or nil. It decides two things: - # which titles are listed at all (the policy's scope) and what each one's `access` - # block says about entitlement. def index(owner_id: nil, subject: nil) softwares = visible_scope(subject) .includes(releases: [ :release_assets ]) diff --git a/app/services/warp_engine/woodpecker_client.rb b/app/services/warp_engine/woodpecker_client.rb index 58f3bd4..bc9cca9 100644 --- a/app/services/warp_engine/woodpecker_client.rb +++ b/app/services/warp_engine/woodpecker_client.rb @@ -21,8 +21,6 @@ module WarpEngine @token = token || WarpEngine.config.woodpecker_api_token end - # --- Repos --- - def list_repos get("/api/repos") end @@ -39,8 +37,6 @@ module WarpEngine delete("/api/repos/#{repo_id}") end - # --- Secrets --- - def list_secrets(repo_id) get("/api/repos/#{repo_id}/secrets") end @@ -59,8 +55,6 @@ module WarpEngine delete("/api/repos/#{repo_id}/secrets/#{secret_name}") end - # --- Pipelines --- - def list_pipelines(repo_id, page: 1, per_page: 25) get("/api/repos/#{repo_id}/pipelines", params: { page: page, perPage: per_page }) @@ -139,8 +133,7 @@ module WarpEngine begin JSON.parse(response.body) rescue JSON::ParserError - # A wrong path falls through to the Woodpecker SPA, which answers - # 200 with index.html — surface that as an API error, not a parse one. + raise ApiError.new( "Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}", status: response.code.to_i, body: response.body diff --git a/config/routes.rb b/config/routes.rb index 517907c..237fa0f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,11 +1,7 @@ WarpEngine::Engine.routes.draw do namespace :api do - # What this deployment is and what it can do. A client reads it before it can have - # a credential, so it is public and cheap. get "service", to: "service#show" - # Device sign-in, for clients that have no browser of their own (RFC 8628). - # Inactive — 404 on every action — unless the host configured a subject class. namespace :auth do post "device", to: "devices#create" post "device/token", to: "devices#token" diff --git a/db/migrate/20260805000001_create_application_tokens.rb b/db/migrate/20260805000001_create_application_tokens.rb index 362b948..10bcaf6 100644 --- a/db/migrate/20260805000001_create_application_tokens.rb +++ b/db/migrate/20260805000001_create_application_tokens.rb @@ -3,8 +3,7 @@ class CreateApplicationTokens < ActiveRecord::Migration[8.1] create_table :application_tokens, id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.string :name, limit: 128, null: false - # The owner class comes from the host (WarpEngine.config.application_token_owner_class), - # so no FK. + t.string :owner_type, limit: 128, null: false t.bigint :owner_id, null: false, unsigned: true t.string :token_digest, limit: 64, null: false diff --git a/db/migrate/20260805000003_add_build_ownership.rb b/db/migrate/20260805000003_add_build_ownership.rb index 4f1cd56..b569b10 100644 --- a/db/migrate/20260805000003_add_build_ownership.rb +++ b/db/migrate/20260805000003_add_build_ownership.rb @@ -1,12 +1,10 @@ class AddBuildOwnership < ActiveRecord::Migration[8.1] def change - # Owner of the publishing token; nil = internal / pre-backfill software. - # The owner class comes from the host (application_token_owner_class), so no FK. + add_column :softwares, :owner_type, :string, limit: 128 add_column :softwares, :owner_id, :bigint, unsigned: true add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner" - # unrestricted = internal token: exempt from enforce_software_ownership. add_column :application_tokens, :unrestricted, :boolean, default: false, null: false end end diff --git a/db/migrate/20260819093412_create_device_grants.rb b/db/migrate/20260819093412_create_device_grants.rb index 204d8d1..63ebc6a 100644 --- a/db/migrate/20260819093412_create_device_grants.rb +++ b/db/migrate/20260819093412_create_device_grants.rb @@ -2,26 +2,16 @@ class CreateDeviceGrants < ActiveRecord::Migration[8.1] def change create_table :device_grants, id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| - # Both codes are secrets in the sense that guessing one grants a session, but they - # have different jobs: the device code is long and never shown to a person, the - # user code is short enough to read off a screen and type into a browser. + t.string :device_code, limit: 64, null: false t.string :user_code, limit: 16, null: false - # What the client called itself. Shown on the approval page, so a person can tell - # which machine is asking, and kept on the issued token as its name. + t.string :client_name, limit: 128 - # The approving subject comes from the host - # (WarpEngine.config.access_token_owner_class), so no FK — same reasoning as - # application_tokens.owner_type. + t.string :subject_type, limit: 128 t.bigint :subject_id, unsigned: true t.bigint :application_token_id, unsigned: true - # The issued token, in the clear, for the seconds between "approved" and "the - # client's next poll". It is cleared on the poll that hands it over, so this - # column holds a live secret only while somebody is waiting for it. There is no - # way around storing it: the approval happens in a browser and the poll arrives on - # a different request, so the two cannot share memory. Everything else about a - # token is stored as a digest — this is the one exception, and it is temporary. + t.string :issued_token, limit: 64 t.datetime :approved_at, precision: 3 t.datetime :denied_at, precision: 3 diff --git a/examples/compose/host_app/config/application.rb b/examples/compose/host_app/config/application.rb index 88c29cd..22dc1d8 100644 --- a/examples/compose/host_app/config/application.rb +++ b/examples/compose/host_app/config/application.rb @@ -17,7 +17,6 @@ module HostApp config.time_zone = "UTC" config.active_record.default_timezone = :utc - # Demo stack: reachable as localhost, gitea-network hostnames, etc. config.hosts.clear end end diff --git a/examples/compose/host_app/config/initializers/warp_engine.rb b/examples/compose/host_app/config/initializers/warp_engine.rb index 901bd5e..f32e484 100644 --- a/examples/compose/host_app/config/initializers/warp_engine.rb +++ b/examples/compose/host_app/config/initializers/warp_engine.rb @@ -3,7 +3,6 @@ Rails.application.config.to_prepare do c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images") - # With no secret configured the /build/* endpoints reject every request. c.update_secret = ENV["UPDATE_SECRET"] end end diff --git a/examples/compose/host_app/config/routes.rb b/examples/compose/host_app/config/routes.rb index ff4d071..d6ec744 100644 --- a/examples/compose/host_app/config/routes.rb +++ b/examples/compose/host_app/config/routes.rb @@ -1,6 +1,5 @@ Rails.application.routes.draw do apipie - # Keep the engine mount the last entry so the host's own routes win. mount WarpEngine::Engine => "/" end diff --git a/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb b/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb index 95cf420..d19679a 100644 --- a/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb +++ b/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb @@ -11,8 +11,7 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0] t.string :site t.string :status, limit: 20, default: "development" t.boolean :highlighted, default: false - # Owner of the publishing token (enforce_software_ownership) — no FK, - # the owner class comes from the host. + t.string :owner_type, limit: 128 t.bigint :owner_id t.datetime :deleted_at, precision: 3 @@ -87,7 +86,7 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0] t.string :token_digest, limit: 64, null: false t.string :token_prefix, limit: 12, null: false t.json :scopes - # Internal token: exempt from enforce_software_ownership. + t.boolean :unrestricted, default: false, null: false t.datetime :expires_at, precision: 3 t.datetime :last_used_at, precision: 3 @@ -98,12 +97,6 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0] t.index :deleted_at end - # NOTE: device_grants is NOT created here. It ships as its own migration in the - # engine's db/migrate, which the host runs from the appended path — so creating it - # here as well would be a second CREATE TABLE for the same name. The same is true - # of application_tokens below, which predates this note and is why a host - # installing today has to delete that block from its copy by hand. - create_table :downloads do |t| t.string :file_path, null: false t.references :release, foreign_key: { on_delete: :nullify }, index: false diff --git a/lib/generators/warp_engine/install/templates/initializer.rb b/lib/generators/warp_engine/install/templates/initializer.rb index e90a1bd..f5dd49d 100644 --- a/lib/generators/warp_engine/install/templates/initializer.rb +++ b/lib/generators/warp_engine/install/templates/initializer.rb @@ -1,81 +1,4 @@ Rails.application.config.to_prepare do WarpEngine.configure do |c| - # Storage locations for build artifacts and images (defaults from ENV: - # FILE_CONTAINER_PATH and IMAGE_CONTAINER_PATH). - # c.file_container_path = "/softwares" - # c.image_container_path = "/images" - - # Shared secret of the /build/* endpoints (default: ENV["UPDATE_SECRET"]). - # With no secret configured the endpoints reject every request. - # c.update_secret = ENV["UPDATE_SECRET"] - - # Auth source of the /build/* endpoints — an exclusive choice: - # :env — the shared secret above is accepted (default) - # :database — only DB-stored WarpEngine::ApplicationToken records are - # accepted (with the "update" scope); 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" - - # Woodpecker configuration extension (/build/config): builder images of - # the served platforms and the CI server's signing key. An empty - # ci_platforms (default) keeps the feature inactive. - # c.ci_platforms = { - # "godot" => { builder: "registry.example/godot-builder:4.6" }, - # "tic80" => { builder: "registry.example/tic80-builder:1.0", - # exporter: "registry.example/tic80pro:1.0" } - # } - # c.ci_extension_public_key_url = "https://ci.example.org/api/signature/public-key" - # c.ci_update_server = nil # nil: the request base_url - - # Woodpecker CI management — repo sync, secret provisioning, pipeline control. - # All three must be set for the management features to activate. - # c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org" - # c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access - # c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org/user (e.g. "games") - - # Size cap in bytes for /build/upload (and the admin file manager, default: 500MB). - # c.max_upload_size = 500 * 1024 * 1024 - - # Owner isolation: a DB token may only upload/publish its own owner's - # softwares (unrestricted tokens are exempt). Enable only after existing - # softwares got an owner (backfill)! - # c.enforce_software_ownership = true - - # Who may see a title and who may download it. :open (the default) lists every - # software and serves every artifact — the behaviour of a catalog nobody sells - # from. A host that does sell supplies a policy answering three methods; see - # WarpEngine::AccessPolicy. What it returns is what clients are told, so a paid - # title can announce itself as paid instead of failing at the download. - # c.access_policy = MyStore::AccessPolicy.new - - # Client sign-in. nil (default) means there is none: /api/auth/* is inactive and - # GET /api/service reports auth: null, so a client offers no sign-in at all. Set - # the class a *client* token belongs to — usually your user model — to turn on - # the device authorization grant. - # c.access_token_owner_class = "User" - # - # Your own page where a signed-in person types the code their client displayed. - # It calls WarpEngine::DeviceGrantService#approve. A path is made absolute - # against the request, so you need not know your own hostname (default "/devices"). - # c.identity_verification_url = "/devices" - # A browser has a session rather than a bearer token, and the engine cannot - # read one. Say how, and your signed-in visitors are recognised on the - # read-only endpoints too: - # c.subject_resolver = ->(request) { request.env["warden"]&.user } - - # c.device_code_ttl = 600 # seconds a pending code lives - # c.device_code_interval = 5 # seconds a client is told to wait between polls - - # If host models also reference catalog images, register them so the - # admin Images page's orphan detection takes them into account: - # 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 diff --git a/lib/warp_engine.rb b/lib/warp_engine.rb index 12f415e..c30143f 100644 --- a/lib/warp_engine.rb +++ b/lib/warp_engine.rb @@ -1,7 +1,3 @@ -# Az engine ActiveJob-ra épülő jobot szállít (PipelineSyncJob), ezért a -# framework betöltése a mi dolgunk: a host application.rb-je nem feltétlenül -# require-öli az active_job/railtie-t, és eager loadnál (production) a -# WarpEngine::ApplicationJob különben uninitialized constant-tal elszáll. require "active_job/railtie" require "blueprinter" @@ -13,9 +9,7 @@ require "warp_engine/storage" require "warp_engine/access" module WarpEngine - # A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace - # által generált "warp_engine_" prefixet üresre cseréljük. Az engine.rb require-je - # előtt kell definiálva lennie. + def self.table_name_prefix "" end @@ -32,10 +26,6 @@ module WarpEngine config.woodpecker_url.present? && config.woodpecker_api_token.present? end - # A host innen tudja, hogy a publikálás ActiveSupport::Notifications-t szór - # ("warp_engine.publish"), és nem kell modell-callbackre kapaszkodnia. - # Régebbi engine-verziókon a metódus nem létezik, ezért a hívó oldalon - # respond_to?-val kérdezendő. def self.instruments_publish? true end @@ -44,13 +34,10 @@ module WarpEngine Storage.adapter end - # Who may see and download what. :open by default — see WarpEngine::AccessPolicy. def self.access_policy AccessPolicy.current end - # The host has configured a subject class, so client sign-in is available. A client - # asks GET /api/service rather than this, but the engine's own controllers need it. def self.identity_configured? config.access_token_owner_class.present? end diff --git a/lib/warp_engine/access.rb b/lib/warp_engine/access.rb index ff7cb26..06c7a93 100644 --- a/lib/warp_engine/access.rb +++ b/lib/warp_engine/access.rb @@ -1,29 +1,7 @@ module WarpEngine - # Who may see a title, and who may download it. - # - # Until now every catalog entry was public and every artifact was free: the API - # listed all software and /api/download handed over any file it could find. That is - # the right default for a catalog nobody sells from, and it stays the default — but a - # host that does sell needs the engine to *say so*, because the clients reading this - # API have no other way to learn it. A desktop client cannot know that a title is - # paid; it can only be told. - # - # This module is that seam. The default policy is byte for byte the previous - # behaviour, and a host swaps it on the configuration: - # - # c.access_policy = MyStorePolicy.new # or :open (default) - # - # A policy is any object answering to this contract: - # - # visible_software_scope(subject:) -> ActiveRecord::Relation - # access_for(software:, subject:) -> WarpEngine::Access - # authorize_download(asset:, subject:, request:) -> Access::Grant or nil - # - # `subject` is whoever the request authenticated as (see SubjectAuthentication), or - # nil for an anonymous caller. It is deliberately untyped here: the engine has no user - # model, and whose object this is belongs to the host. + module AccessPolicy - # Everything visible, everything open, no prices. The catalog as it always was. + class Open def visible_software_scope(subject: nil) WarpEngine::Software.all @@ -33,8 +11,6 @@ module WarpEngine Access::OPEN end - # An open catalog authorises every asset it can find. Returning a bare grant - # rather than `true` keeps one return type across policies. def authorize_download(asset: nil, subject: nil, request: nil) Access::Grant::OPEN end @@ -56,24 +32,14 @@ module WarpEngine @open_policy ||= Open.new end - # Tests and hosts that swap the configuration at runtime. def reset! @open_policy = nil end end end - # What a client is told about one title's availability. - # - # The vocabulary is deliberately generic — `gated`, `entitled`, `price` — because - # every client reading it serves more than one store. A word from any particular - # host's domain ("product", "purchase order", "library") would make the client - # that reads it specific to that host, which is exactly what this engine exists - # to prevent. class Access - # Where a hosted (browser) build is played, when the host serves it somewhere other - # than the engine's own /file/ path. nil leaves the client with what it already - # builds, which is the pre-existing behaviour. + attr_reader :gated, :entitled, :price_cents, :currency, :purchase_url, :web_url def initialize(gated: false, entitled: true, price_cents: nil, currency: nil, @@ -86,9 +52,6 @@ module WarpEngine @web_url = web_url end - # An open catalog's answer, and the shape every response carries even when no - # policy is configured: a client should never have to tell "no access block" from - # "not gated". One of those is a question, the other is an answer. OPEN = new.freeze def as_json(*) @@ -101,10 +64,6 @@ module WarpEngine } end - # A download the policy allowed. - # - # `filename` and `expires_in` let a host override what the engine would otherwise - # decide on its own; both nil means "you choose", which is what the open policy says. class Grant attr_reader :filename, :expires_in diff --git a/lib/warp_engine/configuration.rb b/lib/warp_engine/configuration.rb index dc371be..694abac 100644 --- a/lib/warp_engine/configuration.rb +++ b/lib/warp_engine/configuration.rb @@ -1,56 +1,6 @@ module WarpEngine class Configuration - # Owner contract for image_owners elements: - # label: String - # image_ids: -> { Array } — image ids used by the owner - # usage_label: ->(image) { String or nil } — label to display when in use - # application_token_source: auth source of the /build/* endpoints, exclusive. - # :env — the shared secret (update_secret) is accepted, DB tokens are not - # :database — only WarpEngine::ApplicationToken is accepted, the shared secret is not - # application_token_owner_class: class name of the mandatory token owner - # (e.g. "AdminUser"); nil makes :database mode reject every request. - # storage_adapter: where build artifacts are served from. - # :local (default) — the local filesystem under file_container_path, - # byte for byte the previous behaviour; - # any object — must answer file?/directory?/locate, see - # WarpEngine::Storage. Serving only: uploads and - # archive extraction still write to the local disk. - # max_upload_size: file size cap in bytes for /build/upload (and the admin file manager). - # enforce_software_ownership: when true, a DB token may only upload/publish - # its own owner's softwares (unrestricted tokens are exempt). - # Enable ONLY after the backfill: any token can claim an ownerless software. - # ci_platforms: platforms served by /build/config: - # { "godot" => { builder: "" }, "tic80" => { builder: ..., exporter: ... } } - # Empty map = the feature is inactive (POST → 204, GET → 404). - # ci_extension_public_key(_url): the Woodpecker httpsig ed25519 public key as - # PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key). - # With neither set, POST /build/config rejects every request. - # ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url. - # access_policy: who may see a title and who may download it. - # :open (default) — every software listed, every artifact served, no prices: - # byte for byte the previous behaviour; - # any object — must answer visible_software_scope/access_for/ - # authorize_download, see WarpEngine::AccessPolicy. - # access_token_owner_class: class name of the subject a *client* token belongs to - # (e.g. "Accounts::User"). nil (default) means no client sign-in: /api/auth/* is - # inactive and GET /api/service reports auth: null. Deliberately separate from - # application_token_owner_class, which owns *publishing* tokens — a publisher and - # a customer are rarely the same kind of thing. - # identity_verification_url: the host's own page where a person approves a device - # code. Path or absolute URL; nil falls back to "/devices". The page is the host's - # because approving needs a session, a login and HTML — none of which is the - # engine's business. - # subject_resolver: how to recognise a caller that is not carrying a bearer token — - # a browser with a session, typically. A callable taking the Rack request and - # returning the host's own subject object, or nil: - # c.subject_resolver = ->(request) { request.env["warden"]&.user } - # nil (default) makes every non-bearer request anonymous, which is what the - # read-only API always did. - # device_code_ttl / device_code_interval: how long a pending device code lives, and - # how often a client is told to poll for it. - # woodpecker_url / woodpecker_api_token / woodpecker_repo_owner: - # Woodpecker CI management (repo sync, secret provisioning, pipeline control). - # All nil → the management features are inactive. + attr_accessor :file_container_path, :image_container_path, :update_secret, diff --git a/lib/warp_engine/engine.rb b/lib/warp_engine/engine.rb index 3b10a28..13ecfb1 100644 --- a/lib/warp_engine/engine.rb +++ b/lib/warp_engine/engine.rb @@ -2,23 +2,10 @@ module WarpEngine class Engine < ::Rails::Engine isolate_namespace WarpEngine - # Az app/admin fájlok ActiveAdmin DSL-t tartalmaznak, nem definiálnak a - # fájlnévnek megfelelő konstansokat — a Zeitwerk (eager load) nem nyúlhat hozzájuk. initializer "warp_engine.ignore_admin_dir", before: :setup_main_autoloader do Rails.autoloaders.main.ignore(Engine.root.join("app/admin")) end - # A jövőbeli katalógus-migrációk az engine db/migrate-jéből futnak a host - # rails db:migrate-jével, másolás nélkül. - # - # FONTOS: emiatt az engine és a host migrációi EGY névtérben vannak, és két - # azonos verziószám a host `db:migrate`-jét indulás előtt megállítja - # (DuplicateMigrationVersionError) — nem a miénket, hanem az övét, az egész - # alkalmazásban. Az engine migrációi ezért **valódi, másodperc-pontosságú - # időbélyeget** kapnak (20260819093412), soha nem kerek kézzel írt számot - # (20260819000001): pont az utóbbiakra ír rá egy host, ami ugyanaznap ugyanezzel - # a szokással ír migrációt. Így ütközött a device_grants a katalógus-API - # `carry_the_store_config_in_the_registry`-jével. initializer "warp_engine.append_migrations" do |app| unless app.root.to_s.start_with?(root.to_s) config.paths["db/migrate"].expanded.each do |path| @@ -27,13 +14,11 @@ module WarpEngine end end - # Az admin erőforrásokat a HOST ActiveAdmin példánya tölti be; az engine csak - # regisztrálja a saját app/admin könyvtárát. Auth/téma/route-ok a host dolga. initializer "warp_engine.active_admin" do |app| if defined?(ActiveAdmin) admin_dir = Engine.root.join("app/admin").to_s ActiveAdmin.application.load_paths << admin_dir - # dev módban az engine admin-fájljainak szerkesztése is triggerel reloadot + app.config.watchable_dirs[admin_dir] = [ :rb ] end end diff --git a/lib/warp_engine/storage.rb b/lib/warp_engine/storage.rb index f8f889c..0e77b9b 100644 --- a/lib/warp_engine/storage.rb +++ b/lib/warp_engine/storage.rb @@ -1,26 +1,5 @@ module WarpEngine - # Where the artifacts physically live. - # - # Until now every path in the engine was a local filesystem path. This module - # is the seam a host needs to serve builds from somewhere else (an object - # store behind a CDN, for example) without patching the engine. - # - # The default adapter is :local and behaves exactly as before - same paths, - # same traversal protection, same File.file? checks. - # - # A custom adapter is any object answering to this contract: - # - # file?(relative_path) -> true/false - # directory?(relative_path) -> true/false - # locate(relative_path, filename: nil, expires_in: nil) -> Location - # - # It is set on the configuration: - # - # c.storage_adapter = MyObjectStore.new # or :local (default) - # - # NOTE: ingestion (build/upload, archive extraction, the admin file manager) - # still writes to the local filesystem. A remote adapter therefore needs its - # own upload path today; the serving side is what this seam covers. + module Storage Location = Struct.new(:kind, :path, :url, keyword_init: true) do def file? = kind == :file @@ -30,7 +9,6 @@ module WarpEngine def self.redirect(url) = new(kind: :redirect, url: url) end - # The local filesystem, rooted at config.file_container_path. class LocalAdapter def base_path Pathname.new(WarpEngine.config.file_container_path) @@ -50,15 +28,12 @@ module WarpEngine File.directory?(path) && inside_base?(path) end - # expires_in is part of the contract for signing adapters; the local - # filesystem has nothing to sign, so it is ignored here. def locate(relative_path, filename: nil, expires_in: nil) Location.file(absolute_path(relative_path).to_s) end private - # Path traversal guard: the resolved path must stay under the container. def inside_base?(path) root = base_path.realpath.to_s Pathname.new(path).realpath.to_s.start_with?(root) @@ -83,7 +58,6 @@ module WarpEngine @local_adapter ||= LocalAdapter.new end - # Tests and hosts that swap the configuration at runtime. def reset! @local_adapter = nil end diff --git a/lib/warp_engine/version.rb b/lib/warp_engine/version.rb index e44a1b6..3b9880d 100644 --- a/lib/warp_engine/version.rb +++ b/lib/warp_engine/version.rb @@ -1,8 +1,5 @@ module WarpEngine VERSION = "0.5.2" - # The header every API response carries. Named here rather than written out at the one - # place that sets it: clients read it, the README documents it, and a string in three - # places is a string that eventually differs in one of them. VERSION_HEADER = "WarpEngine-Version".freeze end diff --git a/spec/dummy/app/models/test_owner.rb b/spec/dummy/app/models/test_owner.rb index bfd7571..865748c 100644 --- a/spec/dummy/app/models/test_owner.rb +++ b/spec/dummy/app/models/test_owner.rb @@ -1,3 +1,2 @@ -# Test-only: plays the ApplicationToken owner role in the dummy app. class TestOwner < ActiveRecord::Base end diff --git a/spec/dummy/db/migrate/20260805000002_create_test_owners.rb b/spec/dummy/db/migrate/20260805000002_create_test_owners.rb index 0d5124a..7313462 100644 --- a/spec/dummy/db/migrate/20260805000002_create_test_owners.rb +++ b/spec/dummy/db/migrate/20260805000002_create_test_owners.rb @@ -1,4 +1,3 @@ -# Test-only: plays the ApplicationToken owner role (AdminUser in the host). class CreateTestOwners < ActiveRecord::Migration[8.1] def change create_table :test_owners, id: { type: :bigint, unsigned: true }, diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index ad32046..6738484 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -1,15 +1,3 @@ -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# This file is the source Rails uses to define your schema when running `bin/rails -# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to -# be faster and is potentially less error prone than running all of your -# migrations from scratch. Old migrations may fail to apply correctly if those -# migrations use external dependencies or application code. -# -# It's strongly recommended that you check this file into your version control system. - ActiveRecord::Schema[8.1].define(version: 2026_08_19_093412) do create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", precision: 3 diff --git a/spec/factories/application_tokens.rb b/spec/factories/application_tokens.rb index a1c80e0..462a2a0 100644 --- a/spec/factories/application_tokens.rb +++ b/spec/factories/application_tokens.rb @@ -1,5 +1,3 @@ -# TestOwner exists only in the dummy app — host-side usage must override the -# owner (e.g. owner: create(:admin_user)). FactoryBot.define do factory :test_owner, class: "TestOwner" do name { "test owner" } diff --git a/spec/lib/storage_spec.rb b/spec/lib/storage_spec.rb index e4c8e3d..e432dfb 100644 --- a/spec/lib/storage_spec.rb +++ b/spec/lib/storage_spec.rb @@ -41,7 +41,6 @@ RSpec.describe WarpEngine::Storage do expect(adapter.file?("missing.zip")).to be(false) end - # The path traversal guard has to survive the move behind the adapter. it "refuses paths escaping the container" do outside = File.join(Dir.mktmpdir, "secret.txt") File.write(outside, "nope") diff --git a/spec/migrations_spec.rb b/spec/migrations_spec.rb index f30068e..70cd6c3 100644 --- a/spec/migrations_spec.rb +++ b/spec/migrations_spec.rb @@ -1,25 +1,8 @@ require "rails_helper" -# The engine appends its `db/migrate` to the host's migration paths instead of copying -# migrations into the host, so the two share **one** version namespace. A collision does -# not fail politely somewhere in the engine: it stops the host's `db:migrate` before it -# runs anything, for the whole application. -# -# That is exactly what happened to `device_grants`. It was numbered 20260819000001 — a -# hand-picked round number — and the catalog API had written -# `20260819000001_carry_the_store_config_in_the_registry` on the same day with the same -# habit. Neither repository could see the other's number. -# -# The defence is that engine migrations carry a real second-resolution timestamp, which -# nobody hand-writes and nothing rounds to. This is the test that says so. RSpec.describe "Engine migrations" do - # `202608050000 01`-style numbers: a date, then zeros, then a counter. Rails generates - # `20260819093412`; a person types this. ROUND_VERSION = /\A\d{8}0{4}\d{2}\z/ - # Numbered before this file existed and already deployed everywhere. Renumbering a - # migration that has run is worse than the risk it carries, so they are named here - # rather than quietly excluded by a rule that would also excuse the next one. GRANDFATHERED = %w[ 20260805000001 20260805000003 @@ -49,14 +32,6 @@ RSpec.describe "Engine migrations" do "timestamp — `date -u +%Y%m%d%H%M%S` — not a hand-picked round number." end - # A table created by BOTH the install template and one of our own migrations is a - # second CREATE TABLE for the same name in whatever host installs us — the template - # runs, then the appended engine migration runs, and the second one fails. - # - # `application_tokens` is exactly that, and has been since before this file existed: - # every host installing today has to delete that block from its generated copy by - # hand, which is what teletype-orbit's own migration says in its header. It is - # grandfathered here rather than quietly excused, so the list can only shrink. it "does not create a table the install generator also creates" do template = WarpEngine::Engine.root.join( "lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb" diff --git a/spec/models/pipeline_spec.rb b/spec/models/pipeline_spec.rb index 0900999..17f33ca 100644 --- a/spec/models/pipeline_spec.rb +++ b/spec/models/pipeline_spec.rb @@ -60,9 +60,6 @@ RSpec.describe WarpEngine::Pipeline do it { is_expected.to belong_to(:software).optional } end - # A software has one pipeline. Two pipelines pointing at the same one is not an error - # the database catches, it is a link that silently does nothing — so the newest - # assignment takes it, and says what it took it from. describe "assigning a software another pipeline already has" do it "moves the link and leaves the other pipeline without one" do software = create(:software) diff --git a/spec/models/software_spec.rb b/spec/models/software_spec.rb index 19c348a..1e35cbc 100644 --- a/spec/models/software_spec.rb +++ b/spec/models/software_spec.rb @@ -6,10 +6,9 @@ RSpec.describe WarpEngine::Software, type: :model do it { should validate_presence_of(:name) } it { should validate_presence_of(:title) } it { should validate_presence_of(:platform) } - # MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive + it { should validate_uniqueness_of(:name).case_insensitive } - # a törlést a DB-szintű ON DELETE CASCADE végzi, a modellen nincs dependent opció it { should have_many(:releases) } it { should have_many(:external_links) } it { should have_many(:software_images).dependent(:destroy) } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 6a43560..cd7abd4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -11,7 +11,6 @@ rescue ActiveRecord::PendingMigrationError => e abort e.to_s.strip end -# A factory_bot_rails a dummy app gyökerében keresne; a factory-k az engine spec/ alatt élnek FactoryBot.definition_file_paths = [ File.expand_path("factories", __dir__) ] FactoryBot.reload diff --git a/spec/requests/build_configs_controller_spec.rb b/spec/requests/build_configs_controller_spec.rb index ebd06d5..0c1187c 100644 --- a/spec/requests/build_configs_controller_spec.rb +++ b/spec/requests/build_configs_controller_spec.rb @@ -15,7 +15,6 @@ RSpec.describe "Build configs endpoint", type: :request do allow(WarpEngine.config).to receive(:ci_extension_public_key).and_return(signing_key.public_to_pem) end - # Woodpecker 3.x-style RFC 9421 signature over @request-target + content-digest. def signed_headers(body, path: "/build/config", digest_body: nil) digest = "sha-256=:#{Digest::SHA256.base64digest(digest_body || body)}:" inner = %{("@request-target" "content-digest");created=#{Time.now.to_i};alg="ed25519"} @@ -33,7 +32,6 @@ RSpec.describe "Build configs endpoint", type: :request do } end - # Legacy draft-cavage signature (single Signature header). def cavage_signed_headers(method: "post", path: "/build/config") date = Time.now.httpdate signing_string = "(request-target): #{method} #{path}\ndate: #{date}" diff --git a/spec/requests/device_auth_spec.rb b/spec/requests/device_auth_spec.rb index e709d67..a9ed576 100644 --- a/spec/requests/device_auth_spec.rb +++ b/spec/requests/device_auth_spec.rb @@ -1,13 +1,8 @@ require "rails_helper" -# Signing a client in, end to end: the client asks for a code, a person approves it on -# the host's page, the client's next poll carries the token away, and the token then -# works as a bearer credential on the read-only API. RSpec.describe "Device sign-in", type: :request do let(:owner) { create(:test_owner) } - # The identity seam is off by default. Configuring the subject class is what turns - # the whole flow on — including whether it exists at all. def configure_identity!(verification: "/devices") allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner") allow(WarpEngine.config).to receive(:identity_verification_url).and_return(verification) @@ -37,7 +32,7 @@ RSpec.describe "Device sign-in", type: :request do expect(response).to have_http_status(:ok) json = JSON.parse(response.body) expect(json["deviceCode"]).to be_present - # Grouped and free of I/O/0/1, because it is typed by hand into a browser. + expect(json["userCode"]).to match(/\A[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}\z/) expect(json["verificationUrl"]).to eq("http://www.example.com/devices") expect(json["interval"]).to eq(5) @@ -64,8 +59,6 @@ RSpec.describe "Device sign-in", type: :request do expect(body["token"]).to be_present end - # The plain token is never stored, so it cannot be handed out twice. A client that - # loses it starts again — which is cheaper than a database full of live secrets. it "does not repeat the token on a second poll" do post "/api/auth/device", params: { client_name: "laptop" } json = JSON.parse(response.body) @@ -145,8 +138,6 @@ RSpec.describe "Device sign-in", type: :request do expect(record.scopes).to eq([ "catalog" ]) end - # A publishing token must not become a client token by accident, and vice versa: - # the scope is what separates them, and the catalog endpoint requires its own. it "is not accepted as a publishing credential" do token @@ -197,8 +188,6 @@ RSpec.describe "Device sign-in", type: :request do end end - # A browser carries a session, not a bearer token, and the engine has no idea what a - # session is. A host that wants its signed-in visitors recognised says how. describe "the host's own subject resolver" do let(:seen) { [] } let(:policy) do diff --git a/spec/requests/service_controller_spec.rb b/spec/requests/service_controller_spec.rb index 4c9bf04..ab657da 100644 --- a/spec/requests/service_controller_spec.rb +++ b/spec/requests/service_controller_spec.rb @@ -1,8 +1,5 @@ require "rails_helper" -# The descriptor is how a client stops being built for one particular store: everything -# it used to have compiled in — is there a sign-in, where does it live, can titles be -# gated — is answered here instead. RSpec.describe "GET /api/service", type: :request do after { WarpEngine::AccessPolicy.reset! } @@ -53,8 +50,6 @@ RSpec.describe "GET /api/service", type: :request do expect(auth["device"]["interval"]).to eq(5) end - # A host that configured a bare path should not have to know its own hostname; one - # that put the approval page on another domain should keep it. it "makes a configured path absolute against the request" do get "/api/service" diff --git a/spec/requests/version_header_spec.rb b/spec/requests/version_header_spec.rb index f74304c..6bd4adb 100644 --- a/spec/requests/version_header_spec.rb +++ b/spec/requests/version_header_spec.rb @@ -1,10 +1,6 @@ require "rails_helper" -# Every response the engine serves carries the version that served it, so a client can -# branch on the engine's age without asking a separate endpoint for it. RSpec.describe "the WarpEngine-Version header", type: :request do - # The controller writes the name as a literal so that it cannot depend on a constant a - # half-updated deploy might not have. This is what keeps the two in step. it "is the name the constant documents" do expect(WarpEngine::VERSION_HEADER).to eq("WarpEngine-Version") end @@ -18,9 +14,6 @@ RSpec.describe "the WarpEngine-Version header", type: :request do expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION) end - # The one a client needs most: something came back wrong, and it wants to know whether - # the engine on the other end is old enough to explain it. `rescue_from` never reaches - # an after_action, which is why the header is set before the action runs. it "is on an error response" do get "/api/image/999999" diff --git a/spec/services/access_policy_spec.rb b/spec/services/access_policy_spec.rb index 14f0054..05f1ab1 100644 --- a/spec/services/access_policy_spec.rb +++ b/spec/services/access_policy_spec.rb @@ -1,14 +1,9 @@ require "rails_helper" require "tmpdir" -# The access seam, from both sides: what the catalog says about a title, and whether an -# artifact is handed over. The load-bearing case is the *default* one — a catalog with -# no policy configured has to behave exactly as it did before this existed. RSpec.describe "The access policy" do let(:tmpdir) { Dir.mktmpdir } - # A policy that gates everything except what the subject is named after. Small enough - # to read, and it exercises every method of the contract. let(:gating_policy) do Class.new do def initialize(open_name) = @open_name = open_name @@ -153,8 +148,6 @@ RSpec.describe "The access policy" do before { allow(WarpEngine.config).to receive(:access_policy).and_return(broken_policy) } - # The direction of the failure is the point. A broken gatekeeper must not become an - # open one: an empty catalog is recoverable, a paid title given away is not. it "empties the catalog rather than leaking it" do create(:software, status: "released") diff --git a/spec/services/publish_service_spec.rb b/spec/services/publish_service_spec.rb index b79a02b..a3ae2cc 100644 --- a/spec/services/publish_service_spec.rb +++ b/spec/services/publish_service_spec.rb @@ -64,8 +64,6 @@ RSpec.describe WarpEngine::PublishService do allow(WarpEngine::Platforms::Tic80::Service).to receive(:new).and_return(mock_service) end - # The host reacts to a new build through this event instead of hanging a - # callback on the Release model. it "emits warp_engine.publish with the release in the payload" do payloads = [] ActiveSupport::Notifications.subscribe(described_class::NOTIFICATION) do |*, payload| diff --git a/spec/services/storage_serving_spec.rb b/spec/services/storage_serving_spec.rb index b62182c..9eb06ca 100644 --- a/spec/services/storage_serving_spec.rb +++ b/spec/services/storage_serving_spec.rb @@ -1,13 +1,9 @@ require "rails_helper" require "tmpdir" -# The serving side (FileService, DownloadService) goes through the storage -# adapter. With :local nothing changes; with a custom adapter the same call -# can hand back a redirect instead of a file. RSpec.describe "Serving through the storage adapter" do let(:tmpdir) { Dir.mktmpdir } - # Minimal adapter answering the documented contract. let(:signing_adapter) do Class.new do def file?(_relative) = true