Files
warp_engine/app/admin/device_grants.rb
T
mr.zeroandClaude Opus 5 6c8b026590 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>
2026-08-19 10:26:28 +02:00

63 lines
2.2 KiB
Ruby

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