WarpEngine 0.5.0: a catalog that can say a title is not yours
A desktop client reading /api/software had no way to learn that a title costs money. There was nothing in the response to say so, no way to sign in, and no way to be told "you do not own this" — so a store with paid titles could only hand the client a 403 at download time and let it guess why. The fix belongs here rather than in the client. A client serves more than one store, so anything it knows about a particular one has to arrive from that store's own API; a rule compiled into the client is a rule that breaks every other catalog it reads. Three seams, each following the storage adapter's shape — documented contract, default that is byte for byte the old behaviour, one config key to replace it: - **access policy** — visible_software_scope / access_for / authorize_download. Every catalog entry now carries an `access` block (gated, entitled, price, purchaseUrl, webUrl) and both /api/download and /file/* ask before serving. The vocabulary is deliberately generic: a word from one host's domain would make every client that reads it specific to that host. - **client sign-in** — the device authorization grant (RFC 8628), over the host's own user model. The approval page stays the host's, because approving needs a session and HTML. Tokens are ApplicationTokens with a `catalog` scope, so publishing and reading stay separable. - **service descriptor** — GET /api/service says what this deployment is and whether it has a sign-in at all, which is how a client stops guessing. With no policy and no subject class configured — every deployment today — the API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and the 187 pre-existing examples pass untouched. A policy that raises is treated as a refusal, not permission. An artifact served because the gatekeeper crashed is the one failure mode this must not have, so a broken policy empties the catalog and denies the download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
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",
|
||||
if: proc { WarpEngine.identity_configured? }
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Pending") { |scope| scope.where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
|
||||
scope("Approved") { |scope| scope.where.not(approved_at: nil) }
|
||||
scope("Denied") { |scope| scope.where.not(denied_at: nil) }
|
||||
|
||||
index do
|
||||
id_column
|
||||
column("Code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||
column("Device") { |g| g.client_name }
|
||||
column("State") { |g| status_tag g.state.to_s }
|
||||
column("Who") do |g|
|
||||
next "—" if g.subject_id.blank?
|
||||
|
||||
subject = g.subject
|
||||
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||
end
|
||||
column :expires_at
|
||||
column :created_at
|
||||
end
|
||||
|
||||
filter :client_name_cont, label: "Device"
|
||||
filter :created_at
|
||||
filter :expires_at
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row("User code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||
row("Device") { |g| g.client_name }
|
||||
row("State") { |g| status_tag g.state.to_s }
|
||||
row("Who") do |g|
|
||||
next "—" if g.subject_id.blank?
|
||||
|
||||
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?
|
||||
|
||||
link_to "#{token.token_prefix}… (#{token.name})", admin_application_token_path(token)
|
||||
end
|
||||
row :approved_at
|
||||
row :denied_at
|
||||
row :expires_at
|
||||
row :created_at
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user