Skip to content
Merged
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
4 changes: 3 additions & 1 deletion apps/locallibrary/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,9 @@ Windows builds are experimental and untested on hardware (see Platforms in the R
- **Plugins' accounts and destinations.** A plugin may have accounts on other services, which
locallibrary signs you in to in the browser (`plugin account PLUGIN ACCOUNT sign-in`, or the
player's palette), keeping the tokens in the keychain and refreshing them itself: the plugin
never sees one. A plugin may keep a copy of the store (`store copy --to PLUGIN:DESTINATION`,
never sees one. A service that wants a client secret for its tokens (Mixcloud, SoundCloud,
Google) is signed in to through Local Network's token exchange, which holds the secret, so
none ships with locallibrary. A plugin may keep a copy of the store (`store copy --to PLUGIN:DESTINATION`,
`store restore --from`, `drive verify --from`), every file it gives back checked against its
hash, or publish a set with its recording (`sets publish SET --to`), after a dry run you confirm,
the link it answers kept in the set. The sidecar has both as `plugins.account*`,
Expand Down
8 changes: 8 additions & 0 deletions apps/locallibrary/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ and refreshed a minute before they expire. The plugin never sees them: a request
the account as its `Authorization` header (`{account:ID}`), and locallibrary fills in the token
once it has checked the request, never sending it on to another server a redirect leads to.

Some services (Mixcloud, SoundCloud, Google) want a client secret when the code is traded for
tokens, or when they're refreshed. A secret can't ship inside locallibrary, so a plugin's account
on one of those is signed in to through Local Network's token exchange
(`localnetwork.computer/api/token-exchange/<service>`), which adds the secret and passes the
tokens back. The website never keeps them, and the exchange is the only server besides the
service that a sign-in's code goes to. Until the website is set up for a service, signing in
says so.

## Analysis

An analyzer may also work out a track's beat grid, phrases and waveform (fields of type `beats`,
Expand Down
15 changes: 12 additions & 3 deletions apps/locallibrary/docs/rpc/locallibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7887,12 +7887,21 @@ export type Account = {
authorize_url: string;
/**
* Its token endpoint, where the host trades the browser's code for tokens and refreshes
* them (https).
* them (https). An account has this or `token_exchange`.
*/
token_url: string;
token_url?: string | null;
/**
* Local Network's token exchange for its service
* (`https://localnetwork.computer/api/token-exchange/<service>`), in `token_url`'s place, for
* a service that wants a client secret there.
*/
token_exchange?: string | null;
/** What it asks the service for, as the service names them. */
scopes?: string[];
/** The plugin's client at the service: a public one, without a secret. */
/**
* The plugin's client at the service: a public one, without a secret, or Local Network's,
* whose secret its token exchange holds.
*/
client_id: string;
/** Signing in uses PKCE: always, and a manifest saying otherwise is refused. */
pkce: boolean;
Expand Down
17 changes: 13 additions & 4 deletions apps/locallibrary/docs/rpc/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -30593,8 +30593,18 @@
"description": "The service's authorization endpoint, where the browser signs in (https)."
},
"token_url": {
"type": "string",
"description": "Its token endpoint, where the host trades the browser's code for tokens and refreshes\nthem (https)."
"type": [
"string",
"null"
],
"description": "Its token endpoint, where the host trades the browser's code for tokens and refreshes\nthem (https). An account has this or `token_exchange`."
},
"token_exchange": {
"type": [
"string",
"null"
],
"description": "Local Network's token exchange for its service\n(`https://localnetwork.computer/api/token-exchange/<service>`), in `token_url`'s place, for\na service that wants a client secret there."
},
"scopes": {
"type": "array",
Expand All @@ -30605,7 +30615,7 @@
},
"client_id": {
"type": "string",
"description": "The plugin's client at the service: a public one, without a secret."
"description": "The plugin's client at the service: a public one, without a secret, or Local Network's,\nwhose secret its token exchange holds."
},
"pkce": {
"type": "boolean",
Expand All @@ -30624,7 +30634,6 @@
"id",
"title",
"authorize_url",
"token_url",
"client_id",
"pkce"
],
Expand Down
11 changes: 11 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ HAPPYVIEW_URL=https://happyview.localnetwork.computer
# it, "Report a bug" opens GitHub's issue form for everyone.
# USERINPUT_SPACE=

# Optional: Local Network's clients at the services locallibrary plugins sign in to
# through /api/token-exchange/<service>, whose token endpoints want a client secret.
# src/lib/server/token-exchange/services.json lists the services and their variables.
# A service without both answers that Local Network isn't set up for it yet.
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# MIXCLOUD_CLIENT_ID=
# MIXCLOUD_CLIENT_SECRET=
# SOUNDCLOUD_CLIENT_ID=
# SOUNDCLOUD_CLIENT_SECRET=

# Identity resolution.
PLC_URL=https://plc.directory
# HANDLE_RESOLVER_URL= # dev only: a PDS used to resolve handles like alice.test
Expand Down
9 changes: 9 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ See [`.env.example`](.env.example) for every option. The essentials:
- `HAPPYVIEW_API_KEY`: a HappyView API key with `lexicons:read`, `lexicons:create`, `scripts:read`, `scripts:manage`, `backfill:create`, and `backfill:read`. Optional; without it, upload the lexicons and scripts yourself, and the set picker only lists sets HappyView heard about over Jetstream.
- `LOCALLIBRARY_HOST`: [locallibrary's own host](#tools-pages-and-hosts) (default `locallibrary.localnetwork.computer`).
- `USERINPUT_SPACE`: Local Network's space on [userinput.app](https://userinput.app), as `at://<did>/app.userinput.space/<rkey>`. See [Bug reports](#bug-reports). Optional; without it, "Report a bug" opens GitHub's issue form.
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`, `MIXCLOUD_CLIENT_ID` / `MIXCLOUD_CLIENT_SECRET`, `SOUNDCLOUD_CLIENT_ID` / `SOUNDCLOUD_CLIENT_SECRET`: Local Network's clients at the services locallibrary plugins sign in to through the [token exchange](#token-exchange). Optional; a service without both answers that it isn't set up yet.

## Deploying

Expand Down Expand Up @@ -265,6 +266,12 @@ locallibrary account client-metadata -o src/lib/tools/locallibrary/oauth-client-

Until the host answers, locallibrary signs in as a local development app instead (servers ask about it every time).

### Token exchange

Some services a locallibrary plugin signs in to (Mixcloud, SoundCloud, Google) want a client secret when a sign-in's code is traded for tokens, or when they're refreshed, and a secret can't ship inside an app people download. A plugin's account names `https://localnetwork.computer/api/token-exchange/<service>` as its `token_exchange` instead of a `token_url`, and locallibrary posts that address what it would have sent the service, as JSON. This app adds its client id and secret for the service from the environment, sends the request on the way the service takes it, and answers what the service answered.

The services are in [`src/lib/server/token-exchange/services.json`](src/lib/server/token-exchange/services.json) (schema beside it): each one's token endpoint, how it takes a request (`form`, the usual POST, or `query`, Mixcloud's GET), and the environment variables holding its client id and secret. Adding a service is an entry there and its two variables. The exchange sends nothing anywhere but those token endpoints, logs which service and grant and the status, never a code, token or secret, reads at most 64 KiB of a request or an answer, and counts against the per-address write limit. Until a service's variables are set, it answers `503` with `temporarily_unavailable`, which locallibrary shows as the reason the sign-in failed. Regenerate the schema after changing what an entry has with `SCHEMA_BLESS=1 pnpm -C apps/web vitest run --project server src/lib/server/token-exchange/services.test.ts`.

## Security notes

- OAuth uses DPoP-bound tokens and granular scopes: a permission set ([`permissions.json`](../../lexicons/computer/localnetwork/permissions.json), requested as `include:computer.localnetwork.permissions`) with write-only access to the user's own repos in the audit, moderation, booking, and backstage spaces and a self-authority planner space, and to the shows and follows in their public repo (plus deleting press kits made before the root held them, as they move), plus blob uploads limited to PNG, JPEG, and WebP avatars and JSON tracklists, and calendar events (`repo:community.lexicon.calendar.event`, asked for on its own since a permission set can only grant its own namespace). The PDS grants the set as the scopes it lists. Accounts without spaces support are asked for `atproto` plus the show, follow, and calendar event writes, and fall back to plain `atproto` if their PDS refuses those; they, and anyone who approves only part of the request, have the root account write their space records on their behalf. Sessions granted before the app asked for a new space or blob scope are sent back through sign-in on their next page load. Show, follow, and calendar event writes are asked for when they're needed instead, with a one-click "Sign in again"; a PDS that still won't grant them after that gets a plain "your account's server doesn't allow this yet". Posting bug reports to userinput.app is asked for separately, when someone first sends one, and only allows creating `app.userinput.discussion` records. Nothing lets the app read other users' data with a user's session; shared spaces are read with credentials issued to the root authority.
Expand All @@ -273,6 +280,7 @@ Until the host answers, locallibrary signs in as a local development app instead
- Service endpoints from DID documents are checked against private address ranges in production (SSRF guard).
- Uploaded images are type- and magic-byte-checked and capped at 1 MB; images are served from a dedicated route with a locked-down CSP.
- Rate limits on sign-in, writes, and city search.
- The [token exchange](#token-exchange) only sends requests to the token endpoints `services.json` lists, and never logs what it relays.
- Spaces are access control, not end-to-end encryption: the hosting PDSes and this app can read the data they handle.

## Project layout
Expand All @@ -289,6 +297,7 @@ src/lib/server/audit/ Hash-chained audit log with dual atproto deliver
src/lib/server/happyview/ HappyView client, script bundling, and boot sync
src/lib/server/services/ Projects, requests, staff, bookings, planner
src/lib/server/sync/ Space syncer and record projections
src/lib/server/token-exchange/ The token exchange for locallibrary plugins, and the services it signs in to
src/lib/server/tools/ The tools' own hosts, and their guides rendered to HTML
src/lib/tools/ The tools: one entry per tool, with its guides bundled from apps/<tool>/
src/routes/ SvelteKit 3 pages, form actions, and endpoints
Expand Down
Loading
Loading