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,51 @@
|
||||
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 that token belongs to — the host's own object. nil when the request is
|
||||
# anonymous, when the token is unknown, or when no subject class is configured.
|
||||
def current_subject
|
||||
current_access_token&.owner
|
||||
end
|
||||
|
||||
def resolve_access_token
|
||||
return nil unless WarpEngine.identity_configured?
|
||||
|
||||
token = bearer_token
|
||||
return nil if token.blank?
|
||||
|
||||
record = WarpEngine::ApplicationToken.authenticate(
|
||||
token, required_scope: WarpEngine::ApplicationToken::CATALOG_SCOPE
|
||||
)
|
||||
return nil if record.nil?
|
||||
|
||||
record.touch_last_used!
|
||||
record
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
header = request.headers["Authorization"].to_s
|
||||
return nil unless header.start_with?("Bearer ")
|
||||
|
||||
header.delete_prefix("Bearer ").strip.presence
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user