Skip to content

Let the desktop plugin browser sign in without publish rights - #8

Draft
jankeesvw wants to merge 10 commits into
omacom:mainfrom
jankeesvw:browse-api-publisher-standing
Draft

Let the desktop plugin browser sign in without publish rights#8
jankeesvw wants to merge 10 commits into
omacom:mainfrom
jankeesvw:browse-api-publisher-standing

Conversation

@jankeesvw

Copy link
Copy Markdown

The desktop plugin browser needs to know who you are and to post a rating or a comment. The only token this registry could mint was push-only, minted behind a freshly proved second factor and expiring in seven days. That is the right shape for omarchy plugin publish and the wrong one for an app you leave open.

So api_tokens gain a kind. publish is everything that exists today, unchanged. client can read as you, rate and comment, and can never publish.

The rest follows from that. A small /api/v1 surface for the app, publisher standing on the listing so a grid can render a trust badge without fetching a publisher per card, and the things that turned out to be broken once a second client started reading the same data: the comment limit counted per controller instead of per account, comments_count included hidden comments, and a new rating never invalidated the cached listing.

A native browser renders a trust badge on a card, and until now the only
place that said whether a namespace had been claimed was the publisher
response — so a grid had to fetch a publisher per card to find out, or
imply endorsement it could not back up.

`publisher_claimed` and `publisher_verified` ride along on the shared
plugin partial, which the directory, a publisher's plugin list and a
detail response all render. Flat keys rather than a nested object:
`publisher` is already a string in this shape and clients parse it as one.
Adding fields within a schema_version is what the contract allows.

Development also stops hardcoding its own base URL. Absolute URLs in the
browse API are built from `registry_base_url`, and a client that opens a
plugin page reads them literally — so running the server on any port but
3000 sent people somewhere that was not listening.
The desktop plugin browser needs to know who you are and to post a
rating or a comment. The only token this registry could mint was
push-only, minted behind a freshly proved second factor and expiring in
seven days — the right shape for `omarchy plugin publish` and the wrong
one for an app you leave open.

api_tokens gain a `kind`. `publish` is everything that exists today,
unchanged. `client` can read as you, rate and comment, and can never
publish: the check is at the endpoint, so a publish token posted to a
client endpoint is a 403 and so is the reverse. Client tokens live 30
days and get their own budget, because a handful of laptops must not
lock someone out of shipping a release.

Approving one does not demand a second factor. The browser session doing
the approving can already rate and comment without proving one, so
gating the app would be theatre — and it would lock every account
without MFA out of the app entirely. Publishing keeps the bar; it ships
code to other people's machines. The sensitive-change cooldown still
applies to both. The scope is decided when the authorization row is
created, so nothing the approving browser sends can turn a client
request into a publish token, and an unrecognised scope falls back to
publish rather than to the weaker one.

The device response also carries verification_uri_complete, so a desktop
app can open a page the user only has to approve instead of making them
retype a code the app already knows.

The new /api/v1 surface is small on purpose: `me` and `session` for
identity, and one social payload — rating summary, your own rating, and
the thread — that every write answers with, so the client applies one
response instead of stitching two together. Commenting shares the web
form's five-an-hour budget; a second door onto the same table must not
be the cheap way around the first one's limit. A plugin that has never
been public 404s exactly as a name that never existed does.

Suspension now kills live API tokens the way it already killed browser
sessions, said as forbidden rather than unauthorized — telling a
suspended publisher their token expired only sends them to mint another.
A card shows the comment count next to the rating, and a grid cannot
afford to fetch a thread per card to work it out. It is the counter
cache, so it costs nothing; the thread itself stays on the plugin
response.
Signing in again would otherwise reset it, which makes the limit a
formality. A bearer token also has no business being part of a cache
key, which is what it was.
It guards minting something that can ship code. It was standing in front
of the whole approve action, so three things that mint nothing were
being asked for a passkey they had no business needing.

Denying, first: someone who sees a code they did not ask for must be
able to say no immediately, and gating the safe direction is backwards.
A client sign-in, which can only do what the approving browser session
can already do without a second factor. And a code that expired or was
already answered, where the action reports that itself — telling someone
to go and set up MFA when what happened is that their code ran out
answers the wrong question.
**Denying was blocked by the sensitive-change cooldown.** The
second-factor gate already skipped it — saying no mints nothing, and
gating the safe direction is backwards — but the cooldown did not. An
account in the cooldown is exactly the one most likely to be looking at
a code it did not ask for, and it could not refuse until the code
expired on its own.

**comments_count counted comments nobody can read.** It was a counter
cache, and hiding a comment is a soft delete, so a moderated thread left
a card claiming more than it would show. Comment maintains it under the
plugin lock now, the way Rating already keeps its totals honest, and a
migration brings the existing rows in line with what the column means.

**One key meant two things.** The listing partial published the count as
`comments`, and the detail response layers a `comments` ARRAY over that
same partial — so a plugin parsed as a number in one response and a list
in the other, depending on where a client found it. The count is
`comments_count` now, in both the browse and the client API, and the
social payload carries it so a client never has to count a list that was
truncated at fifty.
The comment on the client API said "the same budget the web form gets",
and it was not: Rails' rate_limit keys its counter on the controller, so
two controllers can never share one however their limits are declared.
An account had five an hour on each door and ten across both.

Counted by hand against a key that names the account and nothing else,
so it is genuinely one budget. By the account rather than the IP, for
the reason OnboardingController already gives about its own limit — an
office behind one address must not share one — and commenting needs an
account either way.

Also stops a first rating racing its own unique index. Two clicks
landing together both see no row and both insert, and the loser got a
500 for an ordinary request; losing that race means the row exists, so
the retry updates it, which is the answer either click deserved.
"My plugins" was a client-side match of a guessed handle against a
plugin's byline. An organisation's plugins carry the organisation's name
and not the names of its members, so that got an org's work wrong in
both directions — missing what you publish with other people, and
claiming what a stranger happens to share a name with.

GET /api/v1/me/plugins returns the manifest ids, across every namespace
the account is an accepted member of. Ids rather than whole entries: the
client already has the listing and only needs to know which rows are
yours. That does mean one still in review is not among them, since the
public listing has no row to mark.
The public read surfaces build their ETag from cache_key_with_version,
which is the row's updated_at. Both totals are recomputed with
update_columns, which does not touch it — so a rating landing changed
the average and nothing else, every If-None-Match got a 304, and a
client holding a cached listing went on showing the old number for as
long as it kept asking politely. Caught by the desktop browser
disagreeing with the website about the same plugin.

The counters that are ALLOWED to go stale are named in ConditionalGet —
downloads and views, incremented with touch: false on purpose. A rating
is not one of them, and neither is a comment count.

Claude-Session: https://claude.ai/code/session_01WjUWG2Z87FpQGdt5MYSDDh
@jankeesvw

Copy link
Copy Markdown
Author

@ryanrhughes I haven't fully verified the code with my eyes, but this code was writing by my agent while it was working on the native app

/me/plugins answered with every plugin of the account's publishers,
while the comment above it promised that one still in review is not
among them. The comment had the better idea. These ids exist to mark
rows the client already has, so an id the directory cannot carry is one
the client can never match — it can only invite it to render a row it
has nothing for. Scoped to directory_visible, and written down in
docs/client-api.md, which had no section for it at all.

Rating something the value it already has is now a no-op. Rating's
after_commit recomputes the plugin's totals and touches updated_at, and
that column is what the plugin's ETag and the directory's are cut from
— so an unguarded update! let one client bust a shared cache as fast as
it could loop without changing a single number. A star control re-sends
freely: clicking the star you already gave, or a second click landing
behind the first, arrives here as the same value.

And the comment budget is spent after the plugin is found rather than
before. Keyed on the account, so it still has to come after
authentication, but a client holding a stale id could burn all five of
the hour's slots on 404s having posted nothing.

Claude-Session: https://claude.ai/code/session_01Q3CDooTHJEALPb1zmgvA8W
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant