Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f713b8a
feat(loclib-plugin): send an account type's params with every sign-in
botandbang Oct 30, 2026
a33847c
fix(locallibrary): sign in again for the scopes of every access, keep…
botandbang Oct 30, 2026
4176d08
feat(locallibrary): give a plugin only the scopes the service granted…
botandbang Oct 30, 2026
c843be0
feat(locallibrary): offer signing in again as the account, and giving…
botandbang Oct 30, 2026
371dd8f
feat(locallibrary): check a plugin's accounts before its destination …
botandbang Oct 30, 2026
b8e2545
feat(locallibrary): refresh once when a service refuses a token, and …
botandbang Oct 30, 2026
5df503a
refactor(youtube): leave a refused token to locallibrary
botandbang Oct 30, 2026
9870b89
refactor(mixcloud): leave a refused token to locallibrary
botandbang Oct 30, 2026
5f582b2
refactor(soundcloud): leave a refused token to locallibrary
botandbang Oct 30, 2026
e8034d9
Merge branch 'bnb/shared-accounts' into bnb/account-escalation
botandbang Oct 30, 2026
7b86828
fix(locallibrary): link only public items from store_destination's docs
botandbang Oct 31, 2026
44e8962
Merge branch 'bnb/shared-accounts' into bnb/account-escalation
botandbang Oct 31, 2026
806715f
docs(locallibrary): say what account escalation changes, in changes/a…
botandbang Oct 31, 2026
b4b7aaf
Merge branch 'bnb/shared-accounts' into bnb/account-escalation
botandbang Nov 1, 2026
d12d85f
Merge branch 'bnb/shared-accounts' into bnb/account-escalation
botandbang Nov 1, 2026
188245e
fix(locallibrary): sign an account out on a 401 only when the service…
botandbang Nov 1, 2026
c8f30a5
Merge branch 'bnb/shared-accounts' into bnb/account-escalation
botandbang Nov 1, 2026
9699d4e
refactor(locallibrary): keep a plugin request's address free of secrets
botandbang Nov 2, 2026
2624416
Merge remote-tracking branch 'origin/main' into bnb/account-escalation
botandbang Nov 2, 2026
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
12 changes: 12 additions & 0 deletions apps/locallibrary/changes/account-escalation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Added

- **More of an account when a plugin needs it.** Signing in again asks the service for every
scope the account's plugins use plus what's added, as the same account (`account TYPE:NAME
sign-in` works for a signed-out account plugins still have access to), and keeps the refresh
token when the service sends no new one. A plugin gets only the scopes the service granted, and
is told which it declined; `plugin access PLUGIN --give ACCOUNT --scope S` gives one later.
Before a plugin destination starts, and again before a publish goes out, its accounts are
checked: signed in, with the scopes it needs, and a token that lasts the upload. A token a
service refuses is refreshed once and the request tried again, the account signed out only
when the service says the token is no good, and a service saying the token lacks a scope fails asking you to sign in again for it. google-account asks Google for offline
access, so its accounts stay signed in.
2 changes: 1 addition & 1 deletion apps/locallibrary/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ its drives.
| `locallibrary account [login\|logout\|status]` | Sign in with your Atmosphere account as the DJ account (the one you use for Bluesky and other AT Protocol apps; alpha), see [Atmosphere account](account.md) |
| `locallibrary account <TYPE> sign-in [--with PLUGIN] [--for PLUGIN] [--scope NAME]...` | Sign in to an account of a type (`google`) in the browser, through an account plugin, giving the plugin `--for` names access to it, see [Accounts](plugins.md#accounts) |
| `locallibrary account <TYPE>:<NAME> [status\|sign-out]` | One of those accounts, or sign out of it (plugins keep their access, unused until it's signed in again) |
| `locallibrary plugin access <PLUGIN> [--default ACCOUNT \| --take-away ACCOUNT]` | A plugin's access to your accounts: make one its default for its type, or take its access away, see [Accounts](plugins.md#accounts) |
| `locallibrary plugin access <PLUGIN> [--give ACCOUNT [--scope S] \| --default ACCOUNT \| --take-away ACCOUNT]` | A plugin's access to your accounts: give it access to one, make one its default for its type, or take its access away, see [Accounts](plugins.md#accounts) |
| `locallibrary plugin [list\|info ID\|settings ID [KEY [VALUE \| --reset \| --secret]]\|roles [ROLE [PLUGIN.ANALYZER \| --reset]]\|checks [PLUGIN.CHECK [--on\|--off] [--blocking\|--no-blocking] [--reset]] [--timeout SECS]\|providers [--auto-pick SCORE \| --reset]]` | The plugins locallibrary has, what each may do and adds, their settings (`--secret` reads a secret one from stdin, kept in the keychain; `services.NAME on` turns a service on), which analyzer each role (a track's key, tempo, level) is read from, which of their checks run with `check` and `doctor` and which guards stop changes they find something about, and the providers `art fetch` and `track fetch` ask, with the score one's candidate is chosen for you at; the commands and operations they add are `locallibrary ID NAME` or where the plugin puts them (`--choose PARAM=VALUE` answers what one asks), see [Plugins](plugins.md#roles) |
| `locallibrary bug [--title T] [-m TEXT] [--no-browser]` | Report a bug, with this version and system filled in: posted from your Atmosphere account to userinput.app, or on GitHub's bug report form, see [Reporting a bug](account.md#reporting-a-bug) |
| `locallibrary completions SHELL [--static]` | Shell completion for bash, zsh, fish, elvish or PowerShell, see [Shell completion and man pages](install.md#shell-completion-and-man-pages) |
Expand Down
40 changes: 29 additions & 11 deletions apps/locallibrary/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,27 @@ plugins that have access to it. `locallibrary account TYPE sign-in` opens the se
page in the browser and waits for it to come back to this computer (`--no-browser` prints the
link, `--paste` takes the address the browser ended up on when it runs on another computer).
`--for PLUGIN` gives a plugin access to it, `--with PLUGIN` picks the account plugin when several
provide the type, and `--scope NAME` asks for one of the type's scopes besides.
provide the type, and `--scope NAME` asks for one of the type's scopes besides. A service may let
you leave some of that out on its page: a plugin then goes without the scopes it can do without
that you left out, and the sign-in says which. `locallibrary account TYPE:NAME sign-in` signs in
again as that account, also once it's signed out, asking for what every plugin's access to it
needs; when the browser signs in to someone else, that account is kept too, but the plugin it was
for gets no access to it.
`locallibrary account TYPE:NAME sign-out` forgets its tokens and tells the service when it can;
plugins keep their access, unused until it's signed in again.

`locallibrary plugin access PLUGIN` lists a plugin's access, `--default ACCOUNT` makes an account
the one it uses for that type, and `--take-away ACCOUNT` takes its access away. Before a plugin's
destination is used, a plugin with no access to a type it needs asks in the terminal which of your
accounts it may use, or to sign in to another. With `--json`, `--yes` or no terminal, and in the
sidecar, nothing is asked: it fails and says how to give it access.
`locallibrary plugin access PLUGIN` lists a plugin's access, `--give ACCOUNT` gives it access to
an account (`--scope NAME` adds a scope it can do without), `--default ACCOUNT` makes an account
the one it uses for that type, and `--take-away ACCOUNT` takes its access away. When a plugin's
request fails because its account is signed out or wasn't granted a scope, the error offers to
sign in again as that account, allowing what's missing. A plugin's destination (and publishing
a set there) starts only once each type the plugin needs has an account it may use: signed in,
with every scope it needs, granted by the service, and, when the service doesn't refresh it,
signed in for an hour yet besides how long sending the recording may take. In the terminal, a
plugin with no access asks which of your accounts it may use, or to sign in to another; one whose
account is signed out, won't stay signed in or wasn't granted what it needs asks to sign in again;
one whose access lacks a scope it needs asks to give it. With `--json`, `--yes` or no terminal,
and in the sidecar, nothing is asked: it fails and says how to resolve it.

In the player's palette, "sign in to a TYPE account…" signs in to one, "sign out of NAME" signs
out of one, and "access for PLUGIN…" opens a form choosing which account of each type the plugin
Expand All @@ -232,7 +244,12 @@ and refreshed a minute before they expire. A plugin never sees them: a request i
account by its type (`{account:TYPE}`) where the type says its token goes, the `Authorization`
header unless the service only takes it in the address (Mixcloud), and locallibrary fills in the
token once it has checked the request against the scopes of the plugin's access, never sending it
on to another server a redirect leads to.
on to another server a redirect leads to. When the service refuses the token (a 401), locallibrary
refreshes it and sends the request once more. A refusal that isn't sent again (the second, or
one for an account with no refresh token) is passed on to the plugin, and signs the account out
only when the service says the token is no good (`error="invalid_token"`); one the service won't
refresh signs it out too. When the service says the token lacks a
scope, the request fails saying which, and signing in again is offered.

Some services (SoundCloud) only take the browser back at the exact address their app was
registered with, port included. An account type on one of those names its port, and its
Expand Down Expand Up @@ -578,10 +595,11 @@ such a request names it (an account's token, only where its account type says it
of the plugin's access covers), filled in by
locallibrary, never on to another server a redirect leads to, and is left out of the plugin's log
and of the errors it's told. What a server answers is passed on as it is, but for an account's
token sent in the address, which comes back as the placeholder that named it. The files a plugin
sends are those in its request's folder (see below) and a set's recording while it publishes the
set, nothing else. A destination is given stored files to read, never the store's folder to
write: what it gives back is checked before locallibrary keeps it.
token sent in the address, which comes back as the placeholder that named it, and a server
refusing an account's token, which locallibrary answers itself (see [Accounts](#accounts)). The
files a plugin sends are those in its request's folder (see below) and a set's recording while it
publishes the set, nothing else. A destination is given stored files to read, never the store's
folder to write: what it gives back is checked before locallibrary keeps it.
Images and other large answers come back as files in a folder locallibrary makes for each request
(`plugins/ID/scratch/`), which it reads itself, never past the folder (nor a link, or a file with
another name) or more than 512 MiB of them, and removes; `store gc` removes those a locallibrary
Expand Down
35 changes: 32 additions & 3 deletions apps/locallibrary/docs/rpc/locallibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2308,7 +2308,10 @@ export type BeginSignIn = {
* can do without.
*/
for?: string | null;
/** An account the store keeps, by subject, to sign in to again (asking for more scopes, say). */
/**
* An account the store keeps, or one signed out that plugins still have access to, by
* subject, to sign in to again (asking for more scopes, say).
*/
account?: string | null;
/** The type's scopes to ask for besides, by name. */
scopes?: string[];
Expand Down Expand Up @@ -2346,6 +2349,14 @@ export type ListAccesses = {
plugin?: string | null;
};

export type GiveAccess = {
/** The plugin's id. */
plugin: string;
account: AccountId;
/** Scopes of the account's type it can do without to give it besides those it needs, by name. */
scopes?: string[];
};

export type AccessRef = {
/** The plugin's id. */
plugin: string;
Expand Down Expand Up @@ -8156,6 +8167,12 @@ export type Access = {
scopes: string[];
/** It's the plugin's default for the type: `{account:<type>}` names it. */
default: boolean;
/**
* The scopes (by name) a sign-in for it asked the service for, which didn't grant all they
* ask, until it's given them again. Those it needs are in `scopes` too, and still can't be
* used.
*/
declined?: string[];
};

/** A sign-in to an account of a type, waiting for the browser. */
Expand Down Expand Up @@ -8196,11 +8213,16 @@ export type AccountSignedIn = {
signed_in: boolean;
/**
* The account it was to sign in again, when the browser signed in to another: that one is
* kept too, and this one as it was.
* kept too, and this one as it was. The plugin it was for isn't given access to it.
*/
instead_of?: string | null;
/** The access it gave the plugin it was signed in for, if it was. */
access?: Access | null;
/**
* The scopes (by name) asked for that plugin that the service didn't grant all they ask:
* those it can do without aren't in its access.
*/
declined?: string[];
};

/** What signing out of an account of a type did. */
Expand Down Expand Up @@ -8405,6 +8427,11 @@ export type AccountType = {
* (`prompt = "select_account"`).
*/
another_account?: { [key: string]: string };
/**
* The authorize params every sign-in sends besides (`access_type = "offline"`), none of
* `one_account`'s or `another_account`'s.
*/
params?: { [key: string]: string };
/** What a plugin may be given access to, by name (`youtube-upload`). */
scopes?: { [key: string]: Scope };
};
Expand Down Expand Up @@ -9624,7 +9651,7 @@ export type Reason =
| "needs_admin"
/**
* Not signed in to an Atmosphere account, or the account a plugin's access is to is signed
* out: sign in.
* out or won't stay signed in long enough: sign in.
*/
| "not_signed_in"
/** The session ended or was revoked: sign in again. */
Expand Down Expand Up @@ -10183,6 +10210,8 @@ export interface Methods {
/** read method */
"accounts.access.list": { params: ListAccesses; result: Access[] };
/** immediate method */
"accounts.access.give": { params: GiveAccess & WriteOptions; result: Access };
/** immediate method */
"accounts.access.set_default": { params: AccessRef & WriteOptions; result: Access };
/** immediate method */
"accounts.access.take_away": { params: AccessRef & WriteOptions; result: Access };
Expand Down
71 changes: 68 additions & 3 deletions apps/locallibrary/docs/rpc/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5079,6 +5079,26 @@
}
}
},
{
"name": "accounts.access.give",
"aliases": [],
"kind": "immediate",
"long_running": false,
"destructive": false,
"params": {
"allOf": [
{
"$ref": "#/$defs/GiveAccess"
},
{
"$ref": "#/$defs/WriteOptions"
}
]
},
"result": {
"$ref": "#/$defs/Access"
}
},
{
"name": "accounts.access.set_default",
"aliases": [],
Expand Down Expand Up @@ -12090,7 +12110,7 @@
"string",
"null"
],
"description": "An account the store keeps, by subject, to sign in to again (asking for more scopes, say).",
"description": "An account the store keeps, or one signed out that plugins still have access to, by\nsubject, to sign in to again (asking for more scopes, say).",
"default": null
},
"scopes": {
Expand Down Expand Up @@ -12195,6 +12215,30 @@
}
}
},
"GiveAccess": {
"type": "object",
"properties": {
"plugin": {
"type": "string",
"description": "The plugin's id."
},
"account": {
"$ref": "#/$defs/AccountId"
},
"scopes": {
"type": "array",
"items": {
"type": "string"
},
"description": "Scopes of the account's type it can do without to give it besides those it needs, by name.",
"default": []
}
},
"required": [
"plugin",
"account"
]
},
"AccessRef": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -31214,6 +31258,13 @@
"type": "boolean",
"description": "It's the plugin's default for the type: `{account:<type>}` names it.",
"default": false
},
"declined": {
"type": "array",
"items": {
"type": "string"
},
"description": "The scopes (by name) a sign-in for it asked the service for, which didn't grant all they\nask, until it's given them again. Those it needs are in `scopes` too, and still can't be\nused."
}
},
"required": [
Expand Down Expand Up @@ -31305,7 +31356,7 @@
"string",
"null"
],
"description": "The account it was to sign in again, when the browser signed in to another: that one is\nkept too, and this one as it was."
"description": "The account it was to sign in again, when the browser signed in to another: that one is\nkept too, and this one as it was. The plugin it was for isn't given access to it."
},
"access": {
"anyOf": [
Expand All @@ -31317,6 +31368,13 @@
}
],
"description": "The access it gave the plugin it was signed in for, if it was."
},
"declined": {
"type": "array",
"items": {
"type": "string"
},
"description": "The scopes (by name) asked for that plugin that the service didn't grant all they ask:\nthose it can do without aren't in its access."
}
},
"required": [
Expand Down Expand Up @@ -31974,6 +32032,13 @@
},
"description": "The authorize params that sign in to another account than those the browser has\n(`prompt = \"select_account\"`)."
},
"params": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "The authorize params every sign-in sends besides (`access_type = \"offline\"`), none of\n`one_account`'s or `another_account`'s."
},
"scopes": {
"type": "object",
"additionalProperties": {
Expand Down Expand Up @@ -35119,7 +35184,7 @@
{
"type": "string",
"const": "not_signed_in",
"description": "Not signed in to an Atmosphere account, or the account a plugin's access is to is signed\nout: sign in."
"description": "Not signed in to an Atmosphere account, or the account a plugin's access is to is signed\nout or won't stay signed in long enough: sign in."
},
{
"type": "string",
Expand Down
30 changes: 19 additions & 11 deletions apps/locallibrary/docs/sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,24 +377,32 @@ the access they're given (see [plugins.md](plugins.md#accounts)). No answer hold
`accounts.list` answers `{accounts, accesses}`: each account kept (`{type, plugin, subject, name,
scopes, expires?, refreshes, signed_in}`, `plugin` the account plugin it was signed in through),
then each one signed out that a plugin still has access to, `signed_in: false`; and each access
(`{id, plugin, type, account: {plugin, subject}, name, scopes, default}`).
(`{id, plugin, type, account: {plugin, subject}, name, scopes, default, declined?}`, `declined`
the scopes the service didn't grant at the last sign-in for that plugin).

`accounts.sign_in.begin {type, with?, for?, account?, scopes?}` starts a sign-in through the
account plugin `with` (the only one providing the type when it's left out), for the plugin `for`
when it's given, again as the account `account` (a subject) when it's given, asking for the type's
`scopes` besides; it answers `{id, type, plugin, url, redirect_uri}`. Open `url` in the browser,
then `accounts.sign_in.finish {id, timeout_secs?}` waits for it to come back (300 seconds unless it
says, an hour at most) and answers the account, with `instead_of` when the browser signed in to
another than `account`, and `access` when it was for a plugin: the access that plugin was given.
when it's given, again as the account `account` (a subject, of one kept or one signed out that a
plugin still has access to) when it's given, asking for the type's `scopes` besides; signing in
again asks for what every access to the account needs too. It answers `{id, type, plugin, url,
redirect_uri}`. Open `url` in the browser, then `accounts.sign_in.finish {id, timeout_secs?}`
waits for it to come back (300 seconds unless it says, an hour at most) and answers the account,
with `instead_of` when the browser signed in to another than `account` (the plugin it was for
isn't given access then), `access` when it was for a plugin: the access that plugin was given, and
`declined`: the scopes asked for that plugin that the service didn't grant. The plugin gets those
it needs all the same, and goes without the others.
`accounts.sign_in.paste {id, url}` finishes with the address the browser ended up on, and
`accounts.sign_in.cancel {id}` answers `{cancelled}`. `accounts.sign_out {account: {plugin,
subject}}` answers the account as it was, with `revoked`; plugins keep their access to it.

`accounts.access.list {plugin?}` answers the accesses, `accounts.access.set_default {plugin,
account}` makes one the plugin's default for its type (giving the access first when it has none),
and `accounts.access.take_away {plugin, account}` answers the access taken away. A plugin's
`account` error with the reason `no_access`, `not_signed_in` or `missing_scope` carries actions
calling `accounts.sign_in.begin` and `accounts.access.set_default`.
`accounts.access.list {plugin?}` answers the accesses, `accounts.access.give {plugin, account,
scopes?}` gives a plugin access to an account (the scopes it needs, and those of `scopes` it can do
without, which aren't declined any more), `accounts.access.set_default {plugin, account}` makes
one the plugin's default for its type (giving the access first when it has none), and
`accounts.access.take_away {plugin, account}` answers the access taken away. A plugin's `account`
error with the reason `no_access`, `not_signed_in` or `missing_scope` carries actions calling
`accounts.sign_in.begin` (again as the account of the plugin's access, for the scopes it's missing,
when that can help) and `accounts.access.set_default`.

## Plugins

Expand Down
Loading
Loading