Skip to content

feat(locallibrary): sign in to each service once, and give plugins access to the account - #335

Merged
botandbang merged 21 commits into
mainfrom
bnb/shared-accounts
Nov 1, 2026
Merged

botandbang merged 21 commits into
mainfrom
bnb/shared-accounts

Conversation

@botandbang

Copy link
Copy Markdown
Contributor

What and why

Follows #289. Each plugin used to declare and keep its own account ([[contributes.accounts]]), so two plugins using Google signed in twice. Now a plugin asks for an account type and the scopes it needs, locallibrary keeps each account once per store, and the user gives each plugin access to an account. This is the second of the account PRs; signing in again for more scopes, accounts/ensure and expiry warnings follow.

Added:

  • [[accounts]] in a plugin's manifest: an account type, its scopes and why, and optional scope groups. A plugin asking for a scope no installed account plugin lists, or for hosts its net: capabilities don't reach, is left out at load.
  • Accounts kept once per store: the record in <store>/accounts.json under a lock, tokens alone in the keychain (account:<plugin>:<subject>@<store>) or the store's secrets file. Sign-in goes through the account type's endpoints, asks who signed in (whoami, same-origin redirects only) and keys the account by account plugin and subject. A refresh happens once per account across processes. invalid_grant signs out; signing out revokes where the type can.
  • Accesses: the scopes one plugin may use on one account, kept beside the accounts so they outlast signing out, with a default per type. {account:<type>} names the default, {account:<type>/<access id>} another. A token is filled in only when an access covers the request's method and URL and the service granted what that scope asks for; otherwise the request fails before it's sent, with kind access or scope and actions built from the manifest. A redirect hop no scope covers loses the token.
  • Surfaces: locallibrary account (lists the DJ account, then every account and who has access), locallibrary account <type> sign-in [--with] [--for] [--scope], <type>:<name> sign-out|status, locallibrary plugin access <plugin> [--default] [--take-away]; sidecar accounts.list, accounts.sign_in.*, accounts.sign_out, accounts.access.*; the player's palette and an access form. Before publishing to a plugin destination, a terminal asks once which account a plugin may use; --json, --yes, no terminal and the sidecar never ask and fail with the command that gives access.
  • A test-only guard: in debug builds the opener launches nothing when LOCALLIBRARY_NO_OPEN is set or never_open() was called. The player's walk property could otherwise start a sign-in through a built-in account plugin and open the real browser on the service's sign-in page. Release builds are unchanged.

Changed:

  • youtube, mixcloud and soundcloud ask for the google, mixcloud and soundcloud account types (scopes youtube-upload, upload, upload), and their 401 messages point at locallibrary account <type> sign-in --for <plugin>.
  • Secrets are kept at a Place shared by plugin secrets and accounts, with no change to where plugin secrets live.

Removed:

  • [[contributes.accounts]], the per-plugin sign-in flow, plugin account and the sidecar's plugins.account*. A manifest that still has the table fails saying contributes.accounts isn't a key locallibrary knows.

How it was tested

  • Hegel properties for each new path: the account manifest table and its mistakes, an account kept once whoever signs in, refreshes one at a time across hosts (fails with the lock removed), a token sent exactly where an access covers, defaults over give/set-default/take-away sequences, the load-time check, RPC sequences, account --json against accounts.list, prompts only where they can be answered, and the nine ways a request through an access is refused. Each new test was broken once to see it fail. No test opens a browser or reaches a real service: they use a mock account plugin and a mock OAuth service.
  • Locally, after merging main: fmt, clippy (workspace and the CLI's CI feature sets), ast-grep, docs/rpc current, full nextest. Coverage: full suite 97.69% lines, 96.68% functions, 95.94% regions; hegel alone 95.62 / 94.04 / 93.71.
  • Not run: clippy for a Windows target (none installed).

Checklist

  • Tests cover the change
  • For locallibrary: apps/locallibrary/CHANGELOG.md has a line under Unreleased
  • The READMEs, docs/ and --help still say what the code does (apps/locallibrary/docs/rpc/ too)

…ts]] table

A plugin can say which account type it needs (google, soundcloud), the scopes it asks
for and why, and optional groups of scopes it asks for later. Each type comes once,
scopes are names asked for once, and the plugin needs a net: capability for the
requests its tokens go into. An account plugin can't ask for accounts itself.
account_placeholder reads {account:<type>} and {account:<type>/<access id>}. Plugins
still sign in with [[contributes.accounts]] until the host fills these.
Reading, writing and moving secrets become free functions over a Place (keychain
service, item key, file), so accounts kept once per store can share them with plugin
secrets. Signing in takes the endpoints it needs from an Endpoints view, built from a
plugin's account or an account type. Plugin secrets are kept exactly where they were.
No behaviour change.
…ccount plugins

An account is kept once per store, named by the account plugin that signed it in and
who signed in there (the account type's whoami answer, a string or a number). Its
record (type, name, granted scopes, expiry) is in <store>/accounts.json under a lock,
and its tokens alone in the keychain (account:<plugin>:<subject>@<store>) or the
store's secrets file. Signing in asks for the type's always scopes, what the account
already has, and what's being added, with one_account's params to sign in again as a
known account and another_account's to add one. The granted scope is read from the
token response, or the asked one when there's none. whoami follows redirects only
within its origin. A refresh happens once per account across processes, under a lock
per account, re-reading the tokens first. invalid_grant signs the account out, and
signing out revokes where the type can. Plugins don't reach these accounts yet;
[[contributes.accounts]] works as before.
… only where its scope covers the request

An access is the scopes one plugin may use on one account, kept beside the accounts in
accounts.json so it outlasts signing out. A plugin's first access for a type is its
default; {account:<type>} names the default and {account:<type>/<access id>} another
of its own. The host fills a token only when the plugin has the access, the account
is signed in, the access still has every scope the manifest requires, one of its
scopes covers the request's method and URL, and the account was granted what that
scope asks the service for. Otherwise the request fails before it's sent, with kind
access or scope and actions built from the plugin's manifest. A redirect hop no
scope covers loses the token. A plugin asking for a scope no installed account plugin
lists, or for hosts its net: capabilities don't reach, is left out at load.
…the sidecar

accounts.list (accounts and accesses, never tokens; a signed-out account with accesses
still listed), accounts.sign_in.begin/finish/paste/cancel (a type, and optionally the
account plugin, the plugin it's for, the account to sign in again as, and scopes),
accounts.sign_out and accounts.access.list/set_default/take_away. A sign-in for a
plugin answers with the access it gave.
…cess, on the command line

locallibrary account lists the DJ account, then every other account with its scopes and
the plugins that have access to it. locallibrary account <type> sign-in (--with, --for,
--scope) adds one, and <type>:<name> sign-out|status works on one. locallibrary plugin
access <plugin> lists a plugin's accesses, with --default and --take-away. Before
publishing to a plugin destination, a terminal asks once which account a plugin
without access may use. --json, --yes, no terminal and the sidecar never ask: they
fail saying which command gives the access.
The palette signs in to each account type, signs out of each account, and opens
"access for <plugin>", a form that names the plugin, the account plugin when a type
has several, the scopes and why the plugin asks.
youtube asks for [[accounts]] type google, scope youtube-upload, in place of its own
Google account, and a 401 points at locallibrary account google sign-in --for youtube.
mixcloud asks for [[accounts]] type mixcloud, scope upload, in place of its own Mixcloud
account, and a 401 points at locallibrary account mixcloud sign-in --for mixcloud.
soundcloud asks for [[accounts]] type soundcloud, scope upload, which covers both the
upload and the look among your newest tracks after one that failed. A 401 points at
locallibrary account soundcloud sign-in --for soundcloud.
Every plugin now signs in through account types, so the per-plugin flow goes: its
sign-in, the {account:<id>} fallback in net.rs, plugin account on the command line,
plugins.account* in the sidecar and its palette entries. The behaviour its tests
covered (the token exchange, declined, foreign and cancelled sign-ins, the port, public
https only, misplaced tokens, redirects) is tested on account types instead. plugin list
says which account types a plugin asks for, and plugin info its accesses.
Its Account struct and validation go. A manifest that still has the table fails saying
contributes.accounts isn't a key locallibrary knows.
…s with properties

The CLI account property now also covers plugin info and serve for an account plugin,
a refused scope, pasting the address a sign-in came back to (and one from another
sign-in), and status for an account that refreshes and one that doesn't. A new
property walks the nine ways a request through an access is refused and checks the
plugin passes each on. Hegel-only coverage goes from 95.32/93.79/93.38 to
95.62/94.04/93.71 (lines/functions/regions).
The player's walk property could start signing in through a built-in account plugin
from the palette, which opened the real browser on the service's sign-in page and
waited for it. In debug builds the opener now refuses to launch anything when
LOCALLIBRARY_NO_OPEN is set or never_open() was called; the CLI test harness sets the
variable and the player's test app calls never_open(), and the walks cancel a sign-in
they start. Release builds are unchanged.
@botandbang
botandbang requested a review from bnb as a code owner October 30, 2026 17:45
bnb
bnb previously approved these changes Oct 30, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file locallibrary locallibrary and the loclib-* crates locallibrary: cli The locallibrary command line locallibrary: player The terminal player and its waveforms locallibrary: sidecar The JSON-RPC sidecar and the api module it serves locallibrary: core The core crate, the data model and rekordbox's formats locallibrary: plugins The plugin protocol, its host and the built-in plugins locallibrary: publishing The Atmosphere account, and the sets and profile published to it tests Tests, fixtures and test harnesses labels Oct 30, 2026
Comment thread apps/locallibrary/src/browser.rs Fixed
# Conflicts:
#	apps/locallibrary/src/drive_cmd.rs
#	apps/locallibrary/src/plugin_cmd/account.rs
#	crates/locallibrary/src/plugin/net.rs
#	crates/locallibrary/tests/it/plugins/net.rs
#	crates/loclib-plugin/src/lib.rs
#	crates/loclib-plugin/src/manifest/tests.rs
bnb
bnb previously approved these changes Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Size

Compared with main. KiB are 1,024 bytes.

This pull request main Change
Client JavaScript 720.2 KiB 720.2 KiB 0 B (0%)
Client JavaScript, brotli 242 KiB 242 KiB 0 B (0%)
Client CSS 90.3 KiB 90.3 KiB 0 B (0%)
Client CSS, brotli 13 KiB 13 KiB 0 B (0%)

@bnb
bnb added this pull request to stack #354 November 1, 2026 16:21
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Rust coverage

From run 91. Both are held to the thresholds in .cargo/config.toml.

Tests Lines Functions Regions
All 97.38% (86004/88321) 96.47% (13870/14378) 95.63% (189583/198245)
Hegel property tests alone 95.39% (84252/88321) 94.02% (13518/14378) 93.48% (185314/198245)

CodeQL reads any name with "account" in it as sensitive, so printing the sign-in link from
begin_account_sign_in and account_login_begin, and anything derived from the account
subcommand's clap variant, counted as logging account data. A barrier model in
.github/codeql/extensions says those return values carry no token, secret or verifier (an
authorize URL with client_id, redirect_uri, scope, state, code_challenge and login_hint),
and the variant is Command::Login, still the `account` subcommand.
@github-actions github-actions Bot added the github_actions Pull requests that update GitHub Actions code label Nov 1, 2026
bnb
bnb previously approved these changes Nov 1, 2026
# Conflicts:
#	crates/locallibrary/src/opener.rs
#	crates/loclib-plugin/README.md
@botandbang
botandbang added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 2854f1c Nov 1, 2026
36 checks passed
@botandbang
botandbang deleted the bnb/shared-accounts branch November 1, 2026 22:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code locallibrary: cli The locallibrary command line locallibrary: core The core crate, the data model and rekordbox's formats locallibrary: player The terminal player and its waveforms locallibrary: plugins The plugin protocol, its host and the built-in plugins locallibrary: publishing The Atmosphere account, and the sets and profile published to it locallibrary: sidecar The JSON-RPC sidecar and the api module it serves locallibrary locallibrary and the loclib-* crates tests Tests, fixtures and test harnesses

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants