Let the desktop plugin browser sign in without publish rights - #8
Draft
jankeesvw wants to merge 10 commits into
Draft
Let the desktop plugin browser sign in without publish rights#8jankeesvw wants to merge 10 commits into
jankeesvw wants to merge 10 commits into
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 publishand the wrong one for an app you leave open.So api_tokens gain a
kind.publishis everything that exists today, unchanged.clientcan read as you, rate and comment, and can never publish.The rest follows from that. A small
/api/v1surface 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.