Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ pipeline with deterministic scanning, capability fingerprints + delta holds,
escalate-only AI review hook, publish hold window, Ed25519-signed index + kill
list, device-flow CLI login, OIDC trusted publishing with provenance, passkeys,
community (ratings/comments/views/reports + moderation), seeding + repo-proof
claims, the admin console, and a JSON browse API for a native in-desktop
plugin browser (docs/browse-api.md — unsigned browse data, never an install
path).
claims, the admin console, and the two JSON APIs a native in-desktop plugin
browser reads and writes: `docs/browse-api.md` (anonymous, cacheable browse
data — never an install path) and `docs/client-api.md` (device-flow sign-in
and the ratings and comments a signed-in app posts, on a token that can never
publish).

**Not yet done, and required before launch**: the Quattro-side client
(`omarchy plugin add/update/publish`, signature + freshness verification,
Expand Down
33 changes: 30 additions & 3 deletions app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,39 @@ def respond_to_missing?(name, include_private = false) = Rails.cache.respond_to?

private

def authenticate_api_token!
# Every authenticated endpoint names the kind of token it accepts. A
# publish token cannot post a comment and a client token cannot publish,
# and neither can be mistaken for the other by forgetting to look.
def authenticate_api_token!(kind:)
raw = request.authorization.to_s[/\ABearer (.+)\z/, 1]
@current_token = ApiToken.authenticate(raw)
render json: { error: "invalid or expired token" }, status: :unauthorized unless @current_token
token = ApiToken.authenticate(raw)

if token.nil?
return render json: { error: "invalid or expired token" }, status: :unauthorized
end
unless token.kind == kind.to_s
return render json: { error: "this token is not a #{kind} token" }, status: :forbidden
end
# Suspension kills live credentials, not just future sign-ins — the same
# rule the cookie session follows. Said as forbidden rather than
# unauthorized: the token is fine, the account is not, and telling a
# suspended publisher their token expired sends them to mint another.
if token.user.suspended_at.present?
return render json: { error: "account is suspended" }, status: :forbidden
end

@current_token = token
# Domain code reads Current.user — comment authorship, the publisher
# badge, audit attribution. Setting it here means an API request and a
# browser request are the same request as far as the models are
# concerned. CurrentAttributes resets between requests.
Current.api_user = token.user
end

def authenticate_publish_token! = authenticate_api_token!(kind: :publish)
def authenticate_client_token! = authenticate_api_token!(kind: :client)

attr_reader :current_token
def current_user = @current_token&.user
end
end
45 changes: 45 additions & 0 deletions app/controllers/api/v1/comments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Api
module V1
class CommentsController < BaseController
include PluginScoped
include CommentRateLimit

before_action :authenticate_client_token!
# Budget after the plugin, not before it. It is keyed on the account, so
# it has to come after authentication — but spending a slot on a request
# that then 404s means a client with a stale id can lose the hour's five
# without posting anything.
before_action :load_plugin!, only: :create
before_action :enforce_comment_budget, only: :create
after_action { response.headers["Cache-Control"] = "no-store" }

def create
comment = @plugin.comments.new(user: current_user, body: params[:body])
if comment.save
render json: social_payload, status: :created
else
render json: { error: comment.errors.full_messages.join("; ") }, status: :unprocessable_entity
end
end

# Authors delete their own comments. Hiding someone else's is moderation
# and stays in the MFA-gated admin controllers, where it leaves an audit
# trail — scoping to the user's own comments is what keeps it that way.
def destroy
comment = current_user.comments.find(params[:id])
plugin = comment.plugin
comment.destroy!
render json: social_payload(plugin)
end

private

# Literally the same budget as the web form, not a second one that
# happens to be the same size — see CommentRateLimit.
def enforce_comment_budget
return unless comment_budget_exceeded?
render json: { error: "slow down — try again in a bit" }, status: :too_many_requests
end
end
end
end
16 changes: 13 additions & 3 deletions app/controllers/api/v1/device_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,25 @@ class DeviceController < BaseController
rate_limit to: 240, within: 15.minutes, only: :token, store: RATE_LIMIT_STORE,
with: -> { render json: { error: "slow_down" }, status: :too_many_requests }

# POST /api/v1/device/code — CLI starts the flow, optionally naming the
# publisher/plugin it wants so the approval page can display the scope
# instead of making the human re-type it
# POST /api/v1/device/code — a device starts the flow, optionally naming
# the publisher/plugin it wants so the approval page can display the
# scope instead of making the human re-type it.
#
# `scope=client` asks for a token that can rate and comment but never
# publish; anything else, including nothing, asks for a publish token.
# verification_uri_complete carries the code in the URL so a desktop app
# can open a page the user only has to approve — the bare
# verification_uri stays for a terminal that can only print one.
def code
authorization = DeviceAuthorization.start!(
token_kind: params[:scope] == "client" ? :client : :publish,
requested_publisher: params[:publisher], requested_plugin: params[:plugin])
render json: {
device_code: authorization.plaintext_device_code,
user_code: authorization.user_code,
verification_uri: "#{DataPlane.base_url}/device",
verification_uri_complete: "#{DataPlane.base_url}/device?code=#{authorization.user_code}",
scope: authorization.token_kind,
expires_in: DeviceAuthorization::EXPIRATION.to_i,
interval: DeviceAuthorization::POLL_INTERVAL
}, status: :created
Expand Down Expand Up @@ -48,6 +57,7 @@ def token
render json: {
token: plaintext,
token_type: "bearer",
kind: authorization.api_token&.kind,
scope: authorization.api_token&.scope_label,
expires_at: authorization.api_token&.expires_at&.utc&.iso8601
}
Expand Down
64 changes: 64 additions & 0 deletions app/controllers/api/v1/me_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module Api
module V1
# Who the signed-in app is talking as, and how it signs out.
class MeController < BaseController
before_action :authenticate_client_token!
after_action { response.headers["Cache-Control"] = "no-store" }

# A client calls this right after the device flow, and again on every
# launch: it is how the app finds out its stored token is still good
# without having to guess from a failed write.
def show
render json: {
user: {
name: current_user.name,
email: current_user.email_address,
admin: current_user.admin?
},
# The namespaces this account publishes under. The browser uses the
# personal one as the handle behind "My plugins", which is a much
# better answer than the one it guesses from installed plugin ids.
publishers: current_user.publishers.map do |publisher|
{ name: publisher.name, kind: publisher.kind,
personal: publisher.personal?, verified: publisher.verified? }
end,
token: {
hint: current_token.token_hint,
expires_at: current_token.expires_at.utc.iso8601,
scope: current_token.scope_label
}
}
end

# GET /api/v1/me/plugins — what this account publishes, as manifest ids.
#
# Membership is the registry's fact and a client cannot derive it from a
# listing: an org's plugins carry the org's name, not the names of the
# people in it, so matching a handle against a byline gets an
# organisation's work wrong in both directions. Asking is the only way
# to be right.
#
# Ids rather than whole entries, because the client already has the
# listing and only needs to know which rows are yours. Scoped to what
# the directory actually shows for the same reason: an id the listing
# cannot contain is one the client can never mark, so a plugin of yours
# still in review is not among them. Answering with it would hand the
# client ids it has nothing to match against and invite it to render a
# row it does not have.
def plugins
ids = Plugin.directory_visible
.where(publisher_id: current_user.publishers.select(:id))
.includes(:publisher).order(:name).map(&:manifest_id)
render json: { plugins: ids.sort }
end

# Signing out in the app revokes the token rather than only forgetting
# it. A token the client has thrown away but the registry still honours
# is exactly the credential nobody notices leaking.
def destroy
current_token.revoke!
head :no_content
end
end
end
end
20 changes: 20 additions & 0 deletions app/controllers/api/v1/plugins_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module Api
module V1
# The viewer's slice of a plugin: the comment thread, and where this
# account stands on it.
#
# The public read of a plugin is the browse API (/plugins/:publisher/:name
# .json), which is anonymous and cacheable. This is the part that cannot
# be: it depends on who is asking, so it lives behind a token and is
# answered no-store.
class PluginsController < BaseController
include PluginScoped

before_action :authenticate_client_token!
before_action :load_plugin!
after_action { response.headers["Cache-Control"] = "no-store" }

def show = render json: social_payload
end
end
end
61 changes: 61 additions & 0 deletions app/controllers/api/v1/ratings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
module Api
module V1
class RatingsController < BaseController
include PluginScoped

before_action :authenticate_client_token!
before_action :load_plugin!
after_action { response.headers["Cache-Control"] = "no-store" }

# Idempotent: rating a plugin you have already rated moves your rating
# rather than failing on the one-per-user constraint, which is what a
# star control does when you click a different star.
def update
value = params[:value].to_i
unless (1..5).cover?(value)
return render json: { error: "value must be between 1 and 5" }, status: :unprocessable_entity
end

upsert_rating(value)
render json: social_payload
end

# Clearing a rating is not the same as rating something one star, so it
# gets its own verb rather than a magic value.
def destroy
@plugin.ratings.find_by(user: current_user)&.destroy!
render json: social_payload
end

private

# find-then-write races the one-rating-per-user index: two clicks landing
# together both see no row and both insert, and the loser gets a 500 for
# what is a perfectly ordinary request. Losing that race means the row
# now exists, so the retry finds it and updates — which is the answer
# either click deserved.
#
# Not covered by a test: reaching it means suspending one request between
# its find and its write, and a test that fakes that convincingly enough
# to be worth reading has not suggested itself. The web form has the same
# shape and the same exposure.
# Re-sending the rating you already have is a no-op, not a write. Rating
# has an after_commit that recomputes the plugin's totals under a lock
# and touches updated_at, which is what the plugin's and the directory's
# ETags are cut from — so an unguarded update! let one client bust a
# shared cache as fast as it could loop, without ever changing a number.
# A star control also re-sends freely: clicking the star you already
# gave, or a second click landing after the first, arrives here as the
# same value.
def upsert_rating(value)
rating = @plugin.ratings.find_or_initialize_by(user: current_user)
return if rating.persisted? && rating.value == value

rating.update!(value: value)
rescue ActiveRecord::RecordNotUnique
existing = @plugin.ratings.find_by!(user: current_user)
existing.update!(value: value) unless existing.value == value
end
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/api/v1/versions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class VersionsController < BaseController
rate_limit to: 30, within: 15.minutes, only: :create, store: RATE_LIMIT_STORE,
with: -> { render json: { error: "slow_down" }, status: :too_many_requests }

before_action :authenticate_api_token!
before_action :authenticate_publish_token!

MAX_BODY_BYTES = Registry::TarballInspector::MAX_TARBALL_BYTES

Expand Down
12 changes: 10 additions & 2 deletions app/controllers/comments_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class CommentsController < ApplicationController
rate_limit to: 5, within: 1.hour, only: :create,
with: -> { redirect_back fallback_location: root_path, alert: "Slow down — try again in a bit." }
include CommentRateLimit

before_action :enforce_comment_budget, only: :create

def create
plugin = find_plugin
Expand All @@ -22,6 +23,13 @@ def destroy

private

# Shared with the client API, so posting through an app is not the cheap way
# around the form's limit.
def enforce_comment_budget
return unless comment_budget_exceeded?
redirect_back fallback_location: root_path, alert: "Slow down — try again in a bit."
end

def find_plugin
Publisher.find_by!(name: params[:publisher]).plugins.find_by!(name: params[:name])
end
Expand Down
67 changes: 67 additions & 0 deletions app/controllers/concerns/api/v1/plugin_scoped.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
module Api
module V1
# Loading a plugin by its "publisher/name" pair, and answering with the
# social state of it. Shared by every endpoint the desktop browser writes
# through, so a rating, a comment and a delete all hand back the same
# shape — the client applies one response and never has to stitch two
# together or refetch to find out what happened.
module PluginScoped
extend ActiveSupport::Concern

# The same fifty the web page shows. A thread longer than this wants
# paging, not a bigger number.
COMMENT_LIMIT = 50

included do
rescue_from ActiveRecord::RecordNotFound do
render json: { error: "not found" }, status: :not_found
end
end

private

def load_plugin!
publisher = Publisher.find_by!(name: params[:publisher])
@plugin = publisher.plugins.find_by!(name: params[:plugin])
# A plugin that has never been public 404s exactly as a name that
# never existed does — the same rule the web page follows, so the API
# is not a way to enumerate what is still in review.
raise ActiveRecord::RecordNotFound unless @plugin.visible_to?(current_user)
end

def social_payload(plugin = @plugin)
comments = plugin.comments.visible.includes(:user).order(created_at: :desc).limit(COMMENT_LIMIT)
# One query for the badge rather than a membership lookup per comment.
member_ids = plugin.publisher.memberships.accepted.pluck(:user_id).to_set

{
plugin: plugin.manifest_id,
# How long the thread actually is, which is not `comments.length`
# when it has been truncated. A client showing a count next to a
# card needs the real number, or a plugin with eighty comments
# starts claiming fifty the moment someone opens it.
comments_count: plugin.comments_count,
rating: {
average: plugin.average_rating,
count: plugin.ratings_count,
mine: plugin.ratings.find_by(user: current_user)&.value
},
comments: comments.map do |comment|
{
id: comment.id,
body: comment.body,
created_at: comment.created_at.utc.iso8601,
# Whether this account can delete it. Authors delete their own;
# everything else is moderation.
mine: comment.user_id == current_user.id,
author: {
name: comment.user.name,
publisher_member: member_ids.include?(comment.user_id)
}
}
end
}
end
end
end
end
Loading