Compare commits
67
Commits
eb1fd8c31a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5afa2d832e | ||
|
|
5ec354cfcb | ||
|
|
2e8ec845c8 | ||
|
|
b0fcd8b81b | ||
|
|
9b1c0a75b2 | ||
|
|
f78abbe05a | ||
|
|
e11e0b6475 | ||
|
|
0a55964db5 | ||
|
|
f1bd75853d | ||
|
|
9af52fd754 | ||
|
|
1e79b4effd | ||
|
|
a9bf51407e | ||
|
|
8bfcd40bd2 | ||
|
|
e930586878 | ||
|
|
05eb947bd1 | ||
|
|
de3340398c | ||
|
|
18c4ebd0b4 | ||
|
|
7d65732f77 | ||
|
|
b5db3c47e1 | ||
|
|
8545b427dd | ||
|
|
965ced52a6 | ||
|
|
4a9f23c759 | ||
|
|
b4dab16d2a | ||
|
|
2c89281bfc | ||
|
|
585c046a64 | ||
|
|
8cc51cfb40 | ||
|
|
8b6cb34ae0 | ||
|
|
8587ae20b7 | ||
|
|
e13128846b | ||
|
|
0e24aa3d52 | ||
|
|
543e88a9a6 | ||
|
|
2e3c935da6 | ||
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 | ||
|
|
fe0012635a | ||
|
|
547662842b |
+30
@@ -63,3 +63,33 @@ scripts/setup/officer-setup/.setup-progress
|
|||||||
# the repository has no ecosystem file at all any more, and the next machine
|
# the repository has no ecosystem file at all any more, and the next machine
|
||||||
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
||||||
ecosystem.config.cjs
|
ecosystem.config.cjs
|
||||||
|
|
||||||
|
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
|
||||||
|
# rewritten on every install. See servers/plugins/generate.ts.
|
||||||
|
build/
|
||||||
|
build.next/
|
||||||
|
src/apps/officer-web/Plugins.gen.tsx
|
||||||
|
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||||
|
|
||||||
|
# drizzle-kit's generated migrations. Nothing applies them — there is no __drizzle_migrations table and
|
||||||
|
# `drizzle-kit migrate` has never been run here; `bun db:push` diffs the schema code against the live
|
||||||
|
# database and alters it directly. The schema code is the source of truth (src/databases/CLAUDE.md).
|
||||||
|
#
|
||||||
|
# Ignored rather than merely unused, because `bun db:gen` reads src/schema.ts — whose last line imports
|
||||||
|
# plugin-schemas.gen.ts, itself generated from the plugin DIRECTORIES on this machine. So a generated
|
||||||
|
# migration describes whichever plugins happen to be checked out here, and committing one would launder
|
||||||
|
# per-machine state into the repository: run it with music installed and history gains music_*; run it on
|
||||||
|
# a fresh clone and the next commit deletes them again.
|
||||||
|
#
|
||||||
|
# The old 0000_new_princess_powerful.sql is deleted from the WORKING TREE but left in history. It had 36
|
||||||
|
# tables and described a schema from before capabilities, api_keys, the app store and the plugin system
|
||||||
|
# existed, including three (task_logs, terminal_containers, queue_jobs) that no longer exist at all.
|
||||||
|
#
|
||||||
|
# Purging it from history was tried on 2026-08-15 and deliberately undone. This codebase cites 76 commit
|
||||||
|
# SHAs in comments and docs as evidence — `totality.ts` points at 2873948 for the websocket incident,
|
||||||
|
# `registry.ts` at 044aacf4, CLAUDE.md at f35c145 — and a filter-repo run rewrites every one of them.
|
||||||
|
# Three stale files nobody reads in old commits are not worth 76 dangling citations in a codebase whose
|
||||||
|
# documentation works by pointing at the commit that proves the claim.
|
||||||
|
#
|
||||||
|
# Generate one locally whenever a diff is useful to read. It stays local.
|
||||||
|
src/databases/officer_db/migrations/
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||||
standing between a Member and a shell.
|
standing between a Member and a shell.
|
||||||
|
|
||||||
|
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||||
|
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
|
||||||
|
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||||
|
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||||
|
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
|
||||||
|
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||||
|
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||||
|
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||||
|
which is why nobody noticed. Found 2026-08-14.
|
||||||
|
|
||||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||||
broken panel or an endless spinner rather than a clean refusal.
|
broken panel or an endless spinner rather than a clean refusal.
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
|
|||||||
coverage = true
|
coverage = true
|
||||||
coverageDir = "coverage"
|
coverageDir = "coverage"
|
||||||
preload = ["./test-setup.ts"]
|
preload = ["./test-setup.ts"]
|
||||||
root = "./src"
|
# The repo, not just `src` — a plugin's tests are the platform's tests.
|
||||||
|
#
|
||||||
|
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
|
||||||
|
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
|
||||||
|
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
|
||||||
|
# extraction would have taken its tests out of the suite the same way.
|
||||||
|
#
|
||||||
|
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
|
||||||
|
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
|
||||||
|
root = "."
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||||
"build:landing": "bun run ./scripts/build/landing.ts",
|
"build:landing": "bun run ./scripts/build/landing.ts",
|
||||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||||
"db:push": "cd src/databases/officer_db && bun run push",
|
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Extracting a feature into a plugin
|
||||||
|
|
||||||
|
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
||||||
|
|
||||||
|
**Read first, in this order:**
|
||||||
|
|
||||||
|
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
||||||
|
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
||||||
|
3. `plugins/offscale/` — the worked example, all four parts
|
||||||
|
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
|
||||||
|
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
|
||||||
|
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The rules. These are not preferences
|
||||||
|
|
||||||
|
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
||||||
|
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
||||||
|
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
||||||
|
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||||
|
|
||||||
|
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||||
|
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
||||||
|
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||||
|
|
||||||
|
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||||
|
|
||||||
|
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
||||||
|
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
||||||
|
structural is convention — presence is the declaration:
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts required
|
||||||
|
api/router.ts a backend router, mounted at mountPrefix()
|
||||||
|
db/schema.ts tables, prefixed <app-name>_
|
||||||
|
sidecar/index.ts a process (.mjs instead means node)
|
||||||
|
web/panels.ts panels — REQUIRED with web/
|
||||||
|
web/layout.ts layout — REQUIRED with web/
|
||||||
|
```
|
||||||
|
|
||||||
|
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
||||||
|
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
||||||
|
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
||||||
|
Music added the field; see its PLUGIN.md for what it is guarding against.
|
||||||
|
|
||||||
|
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||||
|
|
||||||
|
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||||
|
anywhere else means two systems, and only one gets tested.
|
||||||
|
|
||||||
|
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
||||||
|
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
||||||
|
on uninstall. Only deleting a plugin's source can lose its data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The order that worked
|
||||||
|
|
||||||
|
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
||||||
|
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
||||||
|
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
||||||
|
`plugins/<name>/db/`.
|
||||||
|
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
||||||
|
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
||||||
|
reference a plugin may make.
|
||||||
|
4. **Write `manifest.ts`.**
|
||||||
|
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
||||||
|
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||||
|
and `helpers/clipboard` stay as they are.
|
||||||
|
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
||||||
|
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
||||||
|
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||||
|
**both** database barrels (`index.ts` and `schema.ts`).
|
||||||
|
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
||||||
|
8. **Verify on the live server** — see below.
|
||||||
|
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification — run all of it
|
||||||
|
|
||||||
|
```
|
||||||
|
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
||||||
|
pm2 restart officer
|
||||||
|
```
|
||||||
|
|
||||||
|
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||||
|
|
||||||
|
| Step | Expect |
|
||||||
|
| ------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||||
|
| the plugin's API | answers |
|
||||||
|
| the plugin's screen | renders as a Workspace |
|
||||||
|
| dock | tile appears |
|
||||||
|
| permissions page | its permission is listed, read/write/none |
|
||||||
|
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||||
|
| enable | comes back |
|
||||||
|
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||||
|
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||||
|
| install again | identical to the first install |
|
||||||
|
|
||||||
|
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Traps, all of which cost real time once
|
||||||
|
|
||||||
|
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
||||||
|
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
||||||
|
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
||||||
|
`install.ts`; do not reorder it.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
||||||
|
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
||||||
|
pending for the whole protocol.
|
||||||
|
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
||||||
|
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||||
|
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||||
|
contract change. Regenerate rather than hand-edit.
|
||||||
|
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
|
||||||
|
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
||||||
|
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
|
||||||
|
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
||||||
|
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
||||||
|
`catalogue.ts` is the one to read.
|
||||||
|
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||||
|
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||||
|
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||||
|
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
||||||
|
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
||||||
|
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
||||||
|
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
||||||
|
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||||
|
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||||
|
it. Find those before you plan the split; they decide it for you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Music is done. What it changed about this runbook
|
||||||
|
|
||||||
|
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
|
||||||
|
record; the parts worth carrying forward are already folded into the rules and traps above.
|
||||||
|
|
||||||
|
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
||||||
|
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
||||||
|
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
||||||
|
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
||||||
|
none, so it came out whole and made the job look cleaner than it is.
|
||||||
|
|
||||||
|
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
||||||
|
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
||||||
|
not tidy them into a plugin as a warm-up.
|
||||||
|
|
||||||
|
### The global-overlay question is answered, and the answer is no
|
||||||
|
|
||||||
|
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
||||||
|
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
||||||
|
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
||||||
|
gated on its plugin's permission so it switches itself off with the plugin.
|
||||||
|
|
||||||
|
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Which one next
|
||||||
|
|
||||||
|
No decision has been made. What the tree says, for whoever picks it:
|
||||||
|
|
||||||
|
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
||||||
|
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
||||||
|
is exactly what its extraction needs.
|
||||||
|
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
||||||
|
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
||||||
|
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
||||||
|
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
||||||
|
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
||||||
|
way neither of the first two did. Read `docs/secret-store.md` first.
|
||||||
|
|
||||||
|
## Still open, platform-wide. Do not rediscover these
|
||||||
|
|
||||||
|
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
||||||
|
music would have been the first and cliamp being out of scope is what let it pass.
|
||||||
|
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||||
|
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||||
|
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||||
|
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
|
||||||
|
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
||||||
|
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||||
|
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||||
|
owner's. Its own job, not the platform's.
|
||||||
|
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
||||||
|
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
||||||
|
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRouter } from '@@/create-router';
|
||||||
|
|
||||||
|
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||||
|
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||||
|
//
|
||||||
|
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||||
|
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||||
|
export const router = createRouter();
|
||||||
|
|
||||||
|
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
|
||||||
|
// the smallest thing that is still a real one: a manifest and one route.
|
||||||
|
//
|
||||||
|
// Everything structural is convention, so this directory IS the documentation:
|
||||||
|
//
|
||||||
|
// manifest.ts you are here — only what a directory listing cannot say
|
||||||
|
// api/router.ts exports `router`; mounted at /api/example
|
||||||
|
// db/schema.ts tables, if it had any (every name prefixed `example_`)
|
||||||
|
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
|
||||||
|
// web/Router.tsx a frontend, if it had one
|
||||||
|
//
|
||||||
|
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
|
||||||
|
// code sits.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Example',
|
||||||
|
summary: 'The reference plugin — one route, nothing else',
|
||||||
|
icon: 'Puzzle',
|
||||||
|
color: '#94a3b8',
|
||||||
|
|
||||||
|
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
|
||||||
|
// the interesting case, because it is the one the permission gate actually has to resolve.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'example',
|
||||||
|
label: 'Example',
|
||||||
|
description: 'The reference plugin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The reference sidecar: a long-lived process PM2 supervises.
|
||||||
|
//
|
||||||
|
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||||
|
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||||
|
//
|
||||||
|
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||||
|
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||||
|
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||||
|
// installer, and adding a socket here would test Bun rather than that.
|
||||||
|
|
||||||
|
const name = 'officer-example';
|
||||||
|
console.log(`[${name}] started (pid ${process.pid})`);
|
||||||
|
|
||||||
|
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||||
|
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||||
|
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
console.log(`[${name}] ${signal} — exiting`);
|
||||||
|
clearInterval(beat);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
// The second panel, reading the URL rather than being told by its sibling.
|
||||||
|
//
|
||||||
|
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
|
||||||
|
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
|
||||||
|
// independently; nothing is passed between them, so they cannot disagree.
|
||||||
|
export const ExampleDetail = () => {
|
||||||
|
const { section } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
|
||||||
|
<p className="mt-1 text-sm text-duck-dark/60">
|
||||||
|
Section from the URL: <code>{section ?? '(none)'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-duck-dark/40">
|
||||||
|
Try <code>/example/anything</code> — this panel reads it from <code>useParams</code>, with no state passed from
|
||||||
|
the panel beside it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
|
||||||
|
//
|
||||||
|
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
|
||||||
|
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
|
||||||
|
export const ExampleOverview = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['example', 'ping'],
|
||||||
|
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
|
||||||
|
<p className="mt-1 text-sm text-duck-dark/60">
|
||||||
|
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
|
||||||
|
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
|
||||||
|
{isLoading ? <span className="text-duck-dark/40">…</span> : <span>{JSON.stringify(data)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
|
||||||
|
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
|
||||||
|
//
|
||||||
|
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
|
||||||
|
// mismatch falls back rather than rendering another plugin's panel inside this screen.
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'example-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
|
||||||
|
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Puzzle, ListTree } from 'lucide-react';
|
||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { ExampleOverview } from './ExampleOverview';
|
||||||
|
import { ExampleDetail } from './ExampleDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
|
||||||
|
//
|
||||||
|
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
|
||||||
|
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
|
||||||
|
// a rule someone has to remember.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
|
||||||
|
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
||||||
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
||||||
|
|
||||||
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns
|
- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
|
||||||
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
||||||
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
||||||
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
||||||
@@ -28,13 +28,13 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
|
|||||||
|
|
||||||
Byte-range streaming so the player can **seek without downloading the whole file**.
|
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||||
|
|
||||||
| Case | Status | Headers |
|
| Case | Status | Headers |
|
||||||
|---|---|---|
|
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||||
|
|
||||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||||
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
|
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||||
VBR MP3s. No need to scan the file.
|
VBR MP3s. No need to scan the file.
|
||||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||||
|
|
||||||
@@ -51,26 +51,28 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
|
|||||||
this instead of walking + ID3-parsing the library itself.
|
this instead of walking + ID3-parsing the library itself.
|
||||||
|
|
||||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
|
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||||
|
|
||||||
### 2.1 Manifest — one call, whole library
|
### 2.1 Manifest — one call, whole library
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/music/manifest
|
GET /api/music/manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||||
"albums": {
|
"albums": {
|
||||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||||
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||||
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||||
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||||
@@ -82,14 +84,16 @@ may have any mix of `tracks`, `videos`, and `disco`.
|
|||||||
```
|
```
|
||||||
GET /api/music/meta?path=<rel>
|
GET /api/music/meta?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"path": "Albums/AC-DC/[1980] Back in Black",
|
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"cover": "cover.jpg", // present only if a cover exists
|
"cover": "cover.jpg", // present only if a cover exists
|
||||||
"tracks": [
|
"tracks": [
|
||||||
{
|
{
|
||||||
"file": "01 Hells Bells.mp3", // filename within the album folder
|
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||||
"title": "Hells Bells",
|
"title": "Hells Bells",
|
||||||
"artist": "AC/DC",
|
"artist": "AC/DC",
|
||||||
"albumArtist": "AC/DC",
|
"albumArtist": "AC/DC",
|
||||||
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
|||||||
"track": "1",
|
"track": "1",
|
||||||
"year": "1980",
|
"year": "1980",
|
||||||
"durationSec": 312,
|
"durationSec": 312,
|
||||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
],
|
],
|
||||||
"videos": [ // present only for folders that contain video files
|
"videos": [
|
||||||
|
// present only for folders that contain video files
|
||||||
{
|
{
|
||||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||||
"durationSec": 8130,
|
"durationSec": 8130,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||||
omitted entirely when the folder has none.
|
omitted entirely when the folder has none.
|
||||||
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||||
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
|
|||||||
```
|
```
|
||||||
GET /api/music/cover?path=<rel>
|
GET /api/music/cover?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
Only meaningful when the manifest entry has `"cover": true`.
|
Only meaningful when the manifest entry has `"cover": true`.
|
||||||
|
|
||||||
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
|
|||||||
```
|
```
|
||||||
GET /api/music/poster?path=<rel>&file=<video filename>
|
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||||
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
|
|||||||
```
|
```
|
||||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||||
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
|
|||||||
```
|
```
|
||||||
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||||
```
|
```
|
||||||
|
|
||||||
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"artist": "Anthrax",
|
"artist": "Anthrax",
|
||||||
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
|||||||
"[1984] Fistful Of Metal": "Studio",
|
"[1984] Fistful Of Metal": "Studio",
|
||||||
"[1985] Armed And Dangerous": "EP",
|
"[1985] Armed And Dangerous": "EP",
|
||||||
"[1994] The Island Years": "Live",
|
"[1994] The Island Years": "Live",
|
||||||
"[1991] Attack Of The Killer B's": "Compilation"
|
"[1991] Attack Of The Killer B's": "Compilation",
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||||
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||||
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||||
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexStatus`:
|
`IndexStatus`:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"running": true,
|
"running": true,
|
||||||
"startedAt": 1785034701973, "finishedAt": null,
|
"startedAt": 1785034701973,
|
||||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
"finishedAt": null,
|
||||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
"foldersScanned": 45,
|
||||||
|
"albumsBuilt": 12,
|
||||||
|
"albumsSkipped": 3,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"videosIndexed": 4,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"postersSaved": 4,
|
||||||
|
"lyricsIndexed": 45,
|
||||||
|
"discographies": 3,
|
||||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"error": null
|
"error": null,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
GET /api/music/reindex/stream
|
GET /api/music/reindex/stream
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||||
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||||
`IndexReport`) and **closes** the stream.
|
`IndexReport`) and **closes** the stream.
|
||||||
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexReport` (the `done` payload):
|
`IndexReport` (the `done` payload):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
{
|
||||||
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
"albums": 15,
|
||||||
|
"built": 12,
|
||||||
|
"skipped": 3,
|
||||||
|
"foldersScanned": 45,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"discographies": 3,
|
||||||
|
"elapsedSec": 37.2,
|
||||||
|
"error": null,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||||
@@ -256,17 +288,21 @@ Unlike everything above (library data served by the sidecar), these are **per-us
|
|||||||
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
||||||
supplies; the server never interprets them:
|
supplies; the server never interprets them:
|
||||||
|
|
||||||
| kind | key |
|
| kind | key |
|
||||||
|---|---|
|
| -------- | --------------------------------------------------------------------- |
|
||||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||||
| `artist` | music-rel — `Albums/AC-DC` |
|
| `artist` | music-rel — `Albums/AC-DC` |
|
||||||
|
|
||||||
### Favorites
|
### Favorites
|
||||||
|
|
||||||
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||||
```json
|
```json
|
||||||
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
|
{
|
||||||
|
"tracks": ["Music/…/01 Hells Bells.mp3"],
|
||||||
|
"albums": ["Albums/AC-DC/[1980] Back in Black"],
|
||||||
|
"artists": ["Albums/AC-DC"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||||
(a repeat add is a no-op).
|
(a repeat add is a no-op).
|
||||||
@@ -281,9 +317,16 @@ launch to offer "resume".
|
|||||||
|
|
||||||
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||||
```json
|
```json
|
||||||
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
{
|
||||||
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
|
"homePath": "Music/…/01 Hells Bells.mp3",
|
||||||
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
|
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||||
|
"title": "Hells Bells",
|
||||||
|
"artist": "AC/DC",
|
||||||
|
"album": "Back in Black",
|
||||||
|
"durationSec": 312.5,
|
||||||
|
"positionSec": 140,
|
||||||
|
"updatedAt": "2026-07-27T11:27:54.441Z"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||||
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||||
@@ -299,15 +342,15 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
|
|||||||
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
||||||
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||||
|
|
||||||
| method | path | body | returns |
|
| method | path | body | returns |
|
||||||
|---|---|---|---|
|
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
|
||||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||||
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||||
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||||
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||||
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||||
|
|
||||||
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
||||||
|
|
||||||
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
|
|||||||
|
|
||||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
|
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||||
audio files* is a separate, later feature.)
|
audio files_ is a separate, later feature.)
|
||||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Music — the second plugin
|
||||||
|
|
||||||
|
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
|
||||||
|
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
|
||||||
|
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
|
||||||
|
|
||||||
|
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
|
||||||
|
worked second case, and it is interesting mainly for being the messy one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What music is
|
||||||
|
|
||||||
|
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
|
||||||
|
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
|
||||||
|
free to change.
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts identity, one permission
|
||||||
|
api/router.ts re-exports the platform's proxy — see below
|
||||||
|
sidecar/index.ts the whole /api/music contract (503 lines)
|
||||||
|
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
|
||||||
|
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
|
||||||
|
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
|
||||||
|
db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
web/ two panels and a layout; the shell renders the Workspace
|
||||||
|
scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three things that stayed, and why
|
||||||
|
|
||||||
|
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
|
||||||
|
means each one is written down with what would close it.
|
||||||
|
|
||||||
|
### 1. cliamp — parked in the plugin, not left in the platform
|
||||||
|
|
||||||
|
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
|
||||||
|
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
|
||||||
|
|
||||||
|
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
|
||||||
|
|
||||||
|
```
|
||||||
|
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
|
||||||
|
api/cliamp/relay.ts → cliamp/relay.ts
|
||||||
|
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
|
||||||
|
`server.tsx` has no cliamp import, provider name, handler entry or route left.
|
||||||
|
|
||||||
|
Two things went with it that were live rather than inert:
|
||||||
|
|
||||||
|
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
|
||||||
|
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
|
||||||
|
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
|
||||||
|
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
|
||||||
|
its two menu entries are removed; the components are parked here.
|
||||||
|
- **The two socket routes.** They now 404. Verified live.
|
||||||
|
|
||||||
|
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
|
||||||
|
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
|
||||||
|
|
||||||
|
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
|
||||||
|
and never called. A platform gap, not a music one.
|
||||||
|
|
||||||
|
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
|
||||||
|
|
||||||
|
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
|
||||||
|
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
|
||||||
|
`getMusicServerWsUrl` from it, so it could not move.
|
||||||
|
|
||||||
|
That reason was three layers of nothing:
|
||||||
|
|
||||||
|
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
|
||||||
|
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
|
||||||
|
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
|
||||||
|
|
||||||
|
A dead import held a music-named file in the platform. The proxy is now built in
|
||||||
|
`plugins/music/api/router.ts`; the relay takes its URL from there.
|
||||||
|
|
||||||
|
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
|
||||||
|
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
|
||||||
|
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
|
||||||
|
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
|
||||||
|
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
|
||||||
|
to know about provenance. It now calls `mountPrefix`.
|
||||||
|
|
||||||
|
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
|
||||||
|
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
|
||||||
|
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
|
||||||
|
|
||||||
|
### 3. ~~The player~~ — moved, and the reasoning that kept it was removed rather than refuted
|
||||||
|
|
||||||
|
The first version of this document said the player stayed in the platform and called the decision
|
||||||
|
settled by a hard constraint:
|
||||||
|
|
||||||
|
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by `widgets/MusicPlayer/`, the
|
||||||
|
> dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is
|
||||||
|
> decided about the UI.
|
||||||
|
|
||||||
|
True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the
|
||||||
|
complete remaining platform dependency became one line, `DashboardLayout.tsx:66`.
|
||||||
|
|
||||||
|
So the whole of `officerdev/src/MusicPlayer/` now lives in `web/` — engine, state, bar, favourites,
|
||||||
|
lyrics toggle and the library vocabulary. **`src/` contains no music code at all.**
|
||||||
|
|
||||||
|
**Where the engine is mounted, and why it is not the shell.** `MusicPlayerHost` renders inside
|
||||||
|
`MusicDetail`, at the foot of the library view. That reads odd until you notice what it already did:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (pathname.startsWith('/music')) return null; // the panel draws its own MusicMiniBar
|
||||||
|
```
|
||||||
|
|
||||||
|
On `/music` the host has always rendered nothing and existed purely to own the `GaplessEngine`. Mounted
|
||||||
|
in the panel it does exactly that, and the bar code stays intact for whenever there is a slot to put it in.
|
||||||
|
|
||||||
|
**`[phase 2]` Leaving `/music` unmounts the host, which stops playback.** Deliberately deferred rather
|
||||||
|
than solved: making audio outlive the route needs either a shell slot a plugin can contribute to — which
|
||||||
|
reopens "there is no way to export a component" — or the engine hoisted to module scope so a panel
|
||||||
|
attaches and detaches from a singleton. The second keeps the rule and loses only the off-route transport
|
||||||
|
controls, and is the better idea, but it is a rewrite of the host's lifecycle rather than a move.
|
||||||
|
|
||||||
|
Nothing breaks in the meantime, and that was the bar: `player-time`'s `seekPlayer` is optional-chained
|
||||||
|
so a call with no host registered is a no-op, `registerPlayerSeek` clears only its own registration, the
|
||||||
|
host's cleanup destroys the engine and nulls its ref, and the queue lives in global state — so returning
|
||||||
|
to `/music` remounts the host and reloads it.
|
||||||
|
|
||||||
|
## Two bugs, neither visible from reading
|
||||||
|
|
||||||
|
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||||
|
`capabilityAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
|
||||||
|
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||||
|
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||||
|
installed, enabled and healthy.
|
||||||
|
|
||||||
|
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
|
||||||
|
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
|
||||||
|
it cost minutes instead of an evening. Entry removed.
|
||||||
|
|
||||||
|
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
|
||||||
|
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
|
||||||
|
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
|
||||||
|
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
|
||||||
|
so it finds `src/servers/plugins/` and not `plugins/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
||||||
|
every existing `role_capabilities` grant keeps meaning what it meant, and `can('music')` keeps resolving
|
||||||
|
for the overlay. Renaming it would have been a silent data change.
|
||||||
|
|
||||||
|
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
|
||||||
|
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
|
||||||
|
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
|
||||||
|
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
|
||||||
|
therefore permits exactly the four paths it permitted yesterday, and no field was added.
|
||||||
|
|
||||||
|
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
|
||||||
|
|
||||||
|
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
|
||||||
|
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
|
||||||
|
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
|
||||||
|
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
|
||||||
|
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
|
||||||
|
change inside `db/queries.ts`, not a flag on the manifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host dependencies — the field music created
|
||||||
|
|
||||||
|
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
||||||
|
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
||||||
|
that. That was the wrong answer, because of HOW music fails without them.
|
||||||
|
|
||||||
|
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
||||||
|
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
||||||
|
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
||||||
|
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
||||||
|
nobody reads. A refusal wearing the costume of a normal result.
|
||||||
|
|
||||||
|
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
osDependencies: [
|
||||||
|
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
||||||
|
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
||||||
|
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
||||||
|
a single `ffmpeg` before anything reaches a command line.
|
||||||
|
|
||||||
|
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
||||||
|
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
||||||
|
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
||||||
|
recorded why that indirection was rejected.
|
||||||
|
|
||||||
|
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
||||||
|
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
||||||
|
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
||||||
|
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
||||||
|
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
||||||
|
named — nothing had happened, so there was nothing to undo.
|
||||||
|
|
||||||
|
`~/Music` still does not exist, so there is no library to index.
|
||||||
|
|
||||||
|
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||||
|
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified on the live server, 2026-08-15
|
||||||
|
|
||||||
|
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
|
||||||
|
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
|
||||||
|
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
|
||||||
|
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
|
||||||
|
| dock | tile present in `/api/user/capabilities`; `/music` in `routes`, not in `deniedRoutes` |
|
||||||
|
| permissions page | `music` listed among the grantable |
|
||||||
|
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||||
|
| enable | 200 again, sidecar online, favourites still there |
|
||||||
|
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
|
||||||
|
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
|
||||||
|
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
|
||||||
|
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
|
||||||
|
|
||||||
|
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
|
||||||
|
exist before.
|
||||||
|
|
||||||
|
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
|
||||||
|
|
||||||
|
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
|
||||||
|
|
||||||
|
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
|
||||||
|
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** capability, and `files` is
|
||||||
|
**`confined`** — so:
|
||||||
|
|
||||||
|
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
||||||
|
library**, because every listing 403s;
|
||||||
|
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
|
||||||
|
account with no `osUser`, so it means nothing without a per-user Linux account.
|
||||||
|
|
||||||
|
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
|
||||||
|
the one offscale has. Offscale's `ConsoleView` → `TerminalView` is a CODE dependency: it resolves at build
|
||||||
|
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
|
||||||
|
account, and its failure mode is a screen that renders perfectly and shows nothing.
|
||||||
|
|
||||||
|
Three possible shapes, none chosen:
|
||||||
|
|
||||||
|
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
|
||||||
|
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
||||||
|
Most self-contained, and the most work.
|
||||||
|
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
||||||
|
but it makes one plugin's grant conditional on another capability, which is new machinery.
|
||||||
|
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
|
||||||
|
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
|
||||||
|
|
||||||
|
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
|
||||||
|
Not tonight's call.
|
||||||
|
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
|
||||||
|
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
|
||||||
|
wire — noted rather than fixed.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
|
||||||
|
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
|
||||||
|
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
|
||||||
|
- **The cliamp sockets are claimed by no capability**, and are served. Now pinned by a test in
|
||||||
|
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
import { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
|
|
||||||
|
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
|
||||||
|
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
|
||||||
|
//
|
||||||
|
// ── This used to live in the platform, and that was the bug ──
|
||||||
|
//
|
||||||
|
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
|
||||||
|
// knew the string 'music' — and this file merely re-exported it. The justification was that
|
||||||
|
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
|
||||||
|
//
|
||||||
|
// That justification was three layers of nothing. The relay's functions were only reachable through
|
||||||
|
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
|
||||||
|
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
|
||||||
|
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
|
||||||
|
//
|
||||||
|
// ── The prefix is DERIVED, not written ──
|
||||||
|
//
|
||||||
|
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
|
||||||
|
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
|
||||||
|
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
|
||||||
|
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
|
||||||
|
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
|
||||||
|
//
|
||||||
|
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
|
||||||
|
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
|
||||||
|
// become two systems with only one of them tested.
|
||||||
|
//
|
||||||
|
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
|
||||||
|
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
|
||||||
|
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
|
||||||
|
// factory the installer calls instead. Recorded rather than worked around.
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'music',
|
||||||
|
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
|
||||||
|
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
|
||||||
|
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
|
||||||
|
timeoutSeconds: 1800,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = proxy.router;
|
||||||
|
|
||||||
|
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
|
||||||
|
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
+19
-4
@@ -83,7 +83,10 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
|||||||
ctxRef.current = audioCtx;
|
ctxRef.current = audioCtx;
|
||||||
|
|
||||||
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
||||||
if (disposed) { audioCtx.close(); return; }
|
if (disposed) {
|
||||||
|
audioCtx.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
||||||
outputChannelCount: [CHANNELS],
|
outputChannelCount: [CHANNELS],
|
||||||
@@ -138,11 +141,23 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
try { wsRef.current?.close(); } catch { /* ignore */ }
|
try {
|
||||||
|
wsRef.current?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
try { nodeRef.current?.disconnect(); } catch { /* ignore */ }
|
try {
|
||||||
|
nodeRef.current?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
nodeRef.current = null;
|
nodeRef.current = null;
|
||||||
try { audioCtx?.close(); } catch { /* ignore */ }
|
try {
|
||||||
|
audioCtx?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
ctxRef.current = null;
|
ctxRef.current = null;
|
||||||
gainRef.current = null;
|
gainRef.current = null;
|
||||||
};
|
};
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Music } from 'lucide-react';
|
import { Music } from 'lucide-react';
|
||||||
import { TerminalView } from '../Terminal/Terminal';
|
import { TerminalView } from 'officerdev';
|
||||||
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
||||||
|
|
||||||
export const CliampPanelHeader = () => {
|
export const CliampPanelHeader = () => {
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { getMusicServerWsUrl } from '../music/router';
|
import { getMusicServerWsUrl } from '../api/router';
|
||||||
|
|
||||||
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
||||||
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
||||||
// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||||
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
||||||
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||||
|
|
||||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { users } from '../auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||||
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Music — the library, the player, and the phone and tablet apps that stream from it.
|
||||||
|
//
|
||||||
|
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
|
||||||
|
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
|
||||||
|
// of them here after the owner read the code and asked why the platform still had files named for music.
|
||||||
|
// He was right — one of the three "seams" turned out to be dead code holding the door open.
|
||||||
|
//
|
||||||
|
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
|
||||||
|
// cliamp/ the second playback path, parked
|
||||||
|
// widgets/ the dashboard widget, parked
|
||||||
|
// assets/icon.png the dock tile, published to /plugins/music/ on install
|
||||||
|
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
|
||||||
|
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
// web/ the library panels; the shell renders the Workspace
|
||||||
|
//
|
||||||
|
// ── What stayed in the platform, and why ── (nothing. All three moved here.)
|
||||||
|
//
|
||||||
|
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
|
||||||
|
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
|
||||||
|
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
|
||||||
|
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
|
||||||
|
//
|
||||||
|
// 2. ~~The dashboard widget~~ — MOVED HERE, to `./widgets/`, and unregistered from WidgetRegistry.
|
||||||
|
// Parked: plugins cannot contribute widgets and that mechanism is not built.
|
||||||
|
//
|
||||||
|
// 3. ~~The global player overlay~~ — MOVED HERE, to `./web/`. It stayed while the widget pinned
|
||||||
|
// `useMusicPlayer` in `officerdev`; once the widget left, the only platform dependency was one line
|
||||||
|
// in DashboardLayout. `MusicPlayerHost` now mounts inside the MusicDetail panel, where it owns the
|
||||||
|
// audio engine and renders nothing — which is what it already did on /music.
|
||||||
|
//
|
||||||
|
// `[phase 2]` Leaving /music stops playback. Giving audio a life outside the route needs a shell slot
|
||||||
|
// a plugin can contribute to, or the engine hoisted to module scope. Deferred deliberately; nothing
|
||||||
|
// breaks meanwhile.
|
||||||
|
//
|
||||||
|
// ── Host dependencies ──
|
||||||
|
//
|
||||||
|
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
|
||||||
|
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Music',
|
||||||
|
summary: 'The music library — browse, play, favourites and playlists',
|
||||||
|
// No `icon` field: this plugin ships `assets/icon.png` and the file wins. A lucide name could only
|
||||||
|
// ever pick from the 106 glyphs the platform happens to bundle, which is a ceiling a plugin from a
|
||||||
|
// marketplace cannot see coming — and this one's artwork is a voxel duck in headphones, not a glyph.
|
||||||
|
color: '#22c55e',
|
||||||
|
|
||||||
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
|
//
|
||||||
|
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
|
||||||
|
// this extraction, so every existing `role_capabilities` grant keeps meaning what it meant, and the
|
||||||
|
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
|
||||||
|
//
|
||||||
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
|
||||||
|
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
|
||||||
|
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
|
||||||
|
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
|
||||||
|
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'music',
|
||||||
|
label: 'Music',
|
||||||
|
description: 'The music library, playback, and your own favourites and playlists',
|
||||||
|
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
|
||||||
|
//
|
||||||
|
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
|
||||||
|
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
|
||||||
|
// be designing the per-user visibility model that is explicitly not this extraction's work. It
|
||||||
|
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
|
||||||
|
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
|
||||||
|
// a read grant permits exactly the same four paths it permitted yesterday.
|
||||||
|
//
|
||||||
|
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
|
||||||
|
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
|
||||||
|
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
|
||||||
|
//
|
||||||
|
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
|
||||||
|
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
|
||||||
|
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
|
||||||
|
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
|
||||||
|
// least visible. Naming both means the owner is told which of the two they are missing.
|
||||||
|
osDependencies: [
|
||||||
|
{
|
||||||
|
binary: 'ffprobe',
|
||||||
|
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'ffmpeg',
|
||||||
|
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
||||||
import { ensurePulseAudio } from './pulse-audio';
|
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
||||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||||
import {
|
import {
|
||||||
reindexNow,
|
reindexNow,
|
||||||
@@ -37,10 +37,9 @@ import {
|
|||||||
addPlaylistItems,
|
addPlaylistItems,
|
||||||
setPlaylistItems,
|
setPlaylistItems,
|
||||||
type FavoriteKind,
|
type FavoriteKind,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
|
|
||||||
// ── Per-user state validation ──
|
// ── Per-user state validation ──
|
||||||
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
||||||
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
|
|||||||
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
// ── Audio-streaming HTTP server ──
|
// ── Audio-streaming HTTP server ──
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
@@ -476,7 +474,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'music',
|
name: 'music',
|
||||||
capabilities: ['music'],
|
handles: ['music'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
|||||||
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||||
// phone's resync diff (fetch only changed `v`s).
|
// phone's resync diff (fetch only changed `v`s).
|
||||||
|
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
|
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
export const MUSIC_ROOT = join(HOME, 'Music');
|
export const MUSIC_ROOT = join(HOME, 'Music');
|
||||||
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
|||||||
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
||||||
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
||||||
if (prev.version !== next.version) {
|
if (prev.version !== next.version) {
|
||||||
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`);
|
console.log(
|
||||||
|
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { added, removed, changed } = diffManifest(prev, next);
|
const { added, removed, changed } = diffManifest(prev, next);
|
||||||
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
|
console.log(
|
||||||
|
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
|
||||||
|
);
|
||||||
const sample = (label: string, rels: string[]) => {
|
const sample = (label: string, rels: string[]) => {
|
||||||
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
||||||
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
||||||
+3
-1
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
|
|||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
const ms = msUntilNextHour(REINDEX_HOUR);
|
const ms = msUntilNextHour(REINDEX_HOUR);
|
||||||
const at = new Date(Date.now() + ms);
|
const at = new Date(Date.now() + ms);
|
||||||
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`);
|
console.log(
|
||||||
|
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
|
||||||
|
);
|
||||||
timer = setTimeout(async () => {
|
timer = setTimeout(async () => {
|
||||||
console.log('[music] nightly full reindex starting');
|
console.log('[music] nightly full reindex starting');
|
||||||
try {
|
try {
|
||||||
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
|
|||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
|
[
|
||||||
|
'ffprobe',
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-show_entries',
|
||||||
|
'format=duration',
|
||||||
|
'-of',
|
||||||
|
'default=noprint_wrappers=1:nokey=1',
|
||||||
|
absPath,
|
||||||
|
],
|
||||||
{ stdout: 'pipe', stderr: 'ignore' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
const out = (await new Response(proc.stdout).text()).trim();
|
const out = (await new Response(proc.stdout).text()).trim();
|
||||||
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
|
|||||||
}
|
}
|
||||||
return new Response(file.slice(start, end + 1), {
|
return new Response(file.slice(start, end + 1), {
|
||||||
status: 206,
|
status: 206,
|
||||||
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
|
headers: {
|
||||||
|
...baseHeaders,
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||||
|
'Content-Length': String(end - start + 1),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-4
@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||||
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { useMusicFavorites } from './useMusicFavorites';
|
import { useMusicFavorites } from './useMusicFavorites';
|
||||||
import {
|
import {
|
||||||
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
|
|||||||
const albumRel = toRel(albumHome);
|
const albumRel = toRel(albumHome);
|
||||||
try {
|
try {
|
||||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
|
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||||
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
|
albumRel,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
player.playQueue(
|
||||||
|
q,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
q.findIndex((t) => t.file === file),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
player.playQueue([{ albumRel, file }], 0);
|
player.playQueue([{ albumRel, file }], 0);
|
||||||
}
|
}
|
||||||
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
|
|||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
<Heart size={44} className="text-muted-foreground/30" />
|
<Heart size={44} className="text-muted-foreground/30" />
|
||||||
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No favorites yet. Click the heart on any artist, album or track.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
+18
-7
@@ -4,15 +4,16 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||||
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from '../../components/Workspace';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { FavoritesView } from './FavoritesView';
|
import { FavoritesView } from './FavoritesView';
|
||||||
import { useMusicPlayer } from '../../MusicPlayer';
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
import type { PlayerTrack } from '../../MusicPlayer';
|
import type { PlayerTrack } from './useMusicPlayer';
|
||||||
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
|
import { LyricsPanel } from './LyricsPanel';
|
||||||
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
|
import { MusicMiniBar } from './MusicMiniBar';
|
||||||
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
|
import { MusicPlayerHost } from './MusicPlayerHost';
|
||||||
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
import {
|
import {
|
||||||
MUSIC_ROOT,
|
MUSIC_ROOT,
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
@@ -414,6 +415,16 @@ export const MusicDetail = () => {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{content}
|
{content}
|
||||||
<MusicMiniBar />
|
<MusicMiniBar />
|
||||||
|
{/* The audio engine, mounted HERE rather than by the shell.
|
||||||
|
It moved out of DashboardLayout on 2026-08-15 when the player became the plugin's. It renders
|
||||||
|
nothing while the route is /music — the mini bar above is the transport — so this is purely
|
||||||
|
"something owns the GaplessEngine while the screen is open".
|
||||||
|
[phase 2] Leaving /music unmounts it, which stops playback. Making audio outlive the route
|
||||||
|
needs either a shell slot a plugin can contribute to, or the engine hoisted to module scope;
|
||||||
|
that decision is deliberately deferred. Nothing breaks in the meantime: player-time's
|
||||||
|
registrations are optional-chained and the queue lives in global state, so returning to /music
|
||||||
|
remounts the host and reloads it. */}
|
||||||
|
<MusicPlayerHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from 'officerdev';
|
||||||
import { coverUrl, fmtClock } from '../apps/Music/shared';
|
import { coverUrl, fmtClock } from './shared';
|
||||||
import { seekPlayer } from './player-time';
|
import { seekPlayer } from './player-time';
|
||||||
import { useLyricsOpen } from './useLyricsOpen';
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
import { useMusicPlayer } from './useMusicPlayer';
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router';
|
import { Link, useLocation, useNavigate } from 'react-router';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useCapabilities } from 'hooks/useCapabilities';
|
import { usePermissions } from 'hooks/usePermissions';
|
||||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from 'officerdev';
|
||||||
import { MusicHeart } from '../apps/Music/MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
|
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||||
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
||||||
@@ -24,7 +24,7 @@ const MUSIC_API = '/api/music';
|
|||||||
|
|
||||||
export const MusicPlayerHost = () => {
|
export const MusicPlayerHost = () => {
|
||||||
const { token, get, put, delete: del } = useClient();
|
const { token, get, put, delete: del } = useClient();
|
||||||
const { can } = useCapabilities();
|
const { can } = usePermissions();
|
||||||
const canUseMusic = can('music');
|
const canUseMusic = can('music');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { Music, ListMusic } from 'lucide-react';
|
||||||
|
import { MusicBrowser } from './MusicBrowser';
|
||||||
|
import { MusicDetail } from './MusicDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||||
|
// `layout.ts` — a plugin never renders the screen.
|
||||||
|
//
|
||||||
|
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||||
|
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||||
|
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||||
|
// screen.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||||
|
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
|
import type { WidgetRegistryMeta } from 'officerdev';
|
||||||
import { useMusicPlayer } from 'officerdev';
|
// The player is this plugin's own now, so these are siblings rather than host API. Parked: nothing
|
||||||
|
// registers this widget — plugins cannot contribute widgets, and that mechanism is not built.
|
||||||
|
import type { PlayerTrack } from '../web/useMusicPlayer';
|
||||||
|
import { useMusicPlayer } from '../web/useMusicPlayer';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from 'widgets/Widget';
|
||||||
|
|
||||||
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
||||||
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
||||||
@@ -36,7 +39,14 @@ export const MusicPlayer = () => {
|
|||||||
// Top-level libraries (once).
|
// Top-level libraries (once).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
|
.then((r) =>
|
||||||
|
setLibraries(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory')
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.catch(() => setLibraries([]));
|
.catch(() => setLibraries([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -50,7 +60,12 @@ export const MusicPlayer = () => {
|
|||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setDirs(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort());
|
setDirs(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory')
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||||
if (audio.length) {
|
if (audio.length) {
|
||||||
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
|
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
|
||||||
@@ -92,7 +107,12 @@ export const MusicPlayer = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const play = (i: number) => {
|
const play = (i: number) => {
|
||||||
const queue: PlayerTrack[] = songs.map((t) => ({ albumRel: relToMusic, file: t.file, title: t.title, artist: t.artist }));
|
const queue: PlayerTrack[] = songs.map((t) => ({
|
||||||
|
albumRel: relToMusic,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
player.playQueue(queue, i);
|
player.playQueue(queue, i);
|
||||||
};
|
};
|
||||||
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
||||||
@@ -107,7 +127,9 @@ export const MusicPlayer = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectLibrary(lib)}
|
onClick={() => selectLibrary(lib)}
|
||||||
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
||||||
library === lib ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
|
library === lib
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{lib}
|
{lib}
|
||||||
@@ -144,7 +166,9 @@ export const MusicPlayer = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{breadcrumb[breadcrumb.length - 1] ?? library}</p>
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{breadcrumb[breadcrumb.length - 1] ?? library}
|
||||||
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
|
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
# Offscale — the first real plugin
|
||||||
|
|
||||||
|
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
|
||||||
|
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||||
|
|
||||||
|
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
|
||||||
|
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
|
||||||
|
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
|
||||||
|
history of something it no longer knows exists.
|
||||||
|
|
||||||
|
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
|
||||||
|
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
|
||||||
|
|
||||||
|
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
|
||||||
|
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
|
||||||
|
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
|
||||||
|
stock. The distinct name marks a distinct product, not a badge on someone else's.
|
||||||
|
|
||||||
|
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
|
||||||
|
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The reframe
|
||||||
|
|
||||||
|
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
|
||||||
|
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
|
||||||
|
intuition is that it is one plugin requiring two sidecars.
|
||||||
|
|
||||||
|
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
|
||||||
|
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
|
||||||
|
written on runs five.
|
||||||
|
|
||||||
|
### Two words, because "core" was doing two jobs
|
||||||
|
|
||||||
|
- **baseline** — what a fresh install actually runs
|
||||||
|
- **first-party** — what Officer Dev publishes
|
||||||
|
|
||||||
|
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
|
||||||
|
"is X core?" a question with two answers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a plugin is made of
|
||||||
|
|
||||||
|
Combined per plugin as needed. **Only `meta` and the ID are always required.**
|
||||||
|
|
||||||
|
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
|
||||||
|
- an **ID** (see below)
|
||||||
|
- a **sidecar**
|
||||||
|
- a **backend router** and its routes
|
||||||
|
- a **db schema**
|
||||||
|
- **default permissions per user group**
|
||||||
|
- what it stores in the **secret store**, and whether that is per-user or plugin-global
|
||||||
|
- a **frontend router**, its routes, and the frontend code
|
||||||
|
- how it **mounts into the file browser context menu**
|
||||||
|
- a set of **capabilities added to officer-items**
|
||||||
|
- **plugin settings page** definitions
|
||||||
|
- an accompanying **mobile app**
|
||||||
|
|
||||||
|
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
|
||||||
|
mount, whether its sidecar is in the ecosystem file, and so on.
|
||||||
|
|
||||||
|
### What offscale needs
|
||||||
|
|
||||||
|
db schema · backend router + routes · frontend router + routes · sidecar.
|
||||||
|
|
||||||
|
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity and routing
|
||||||
|
|
||||||
|
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
|
||||||
|
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
|
||||||
|
across two namespaces means whichever is weaker becomes the real attack surface.
|
||||||
|
|
||||||
|
**Uniqueness comes from two mechanisms**, because one is not enough:
|
||||||
|
|
||||||
|
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
|
||||||
|
generic as `notes` gets refused: it is a name Officer Dev may want later.
|
||||||
|
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
|
||||||
|
Needed because a private plugin never asks the marketplace anything.
|
||||||
|
|
||||||
|
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
|
||||||
|
restrictions; publishing is what invites review.
|
||||||
|
|
||||||
|
### Mount prefixes
|
||||||
|
|
||||||
|
```
|
||||||
|
first-party /api/<app-name> e.g. /api/offscale
|
||||||
|
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
|
||||||
|
```
|
||||||
|
|
||||||
|
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
|
||||||
|
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
|
||||||
|
|
||||||
|
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
|
||||||
|
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
|
||||||
|
special case in the router, a bypassed check, a different install branch — first-party and third-party
|
||||||
|
become two systems, and only one of them gets tested.
|
||||||
|
|
||||||
|
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
|
||||||
|
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
|
||||||
|
adding core routes forever without breaking installs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The database
|
||||||
|
|
||||||
|
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||||
|
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||||
|
|
||||||
|
### A Postgres schema per plugin was tested and rejected
|
||||||
|
|
||||||
|
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
|
||||||
|
earns. Recorded so nobody re-runs the experiment:
|
||||||
|
|
||||||
|
| Property | Result |
|
||||||
|
| ----------------------------------------------------------------- | ------------------------- |
|
||||||
|
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
|
||||||
|
| Cross-schema FK to `public.users` | works |
|
||||||
|
| Partial unique index preserved | works |
|
||||||
|
| Push is idempotent, no spurious re-creation | works |
|
||||||
|
| Cascade delete across the schema boundary | works |
|
||||||
|
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
|
||||||
|
|
||||||
|
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
|
||||||
|
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
|
||||||
|
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
|
||||||
|
naming the schema in `schemaFilter` made the identical push work.
|
||||||
|
|
||||||
|
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
|
||||||
|
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
|
||||||
|
costume of a normal result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mounting — rebuild and swap, at runtime
|
||||||
|
|
||||||
|
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
|
||||||
|
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
|
||||||
|
|
||||||
|
### What was actually tested
|
||||||
|
|
||||||
|
| Router | `app.route()` after serving has begun |
|
||||||
|
| -------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
|
||||||
|
| `RegExpRouter` | **throws**, same reason |
|
||||||
|
| `TrieRouter` | works |
|
||||||
|
| `PatternRouter` | works |
|
||||||
|
|
||||||
|
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
|
||||||
|
remove a route**, which uninstall needs.
|
||||||
|
|
||||||
|
### The approach that solves both
|
||||||
|
|
||||||
|
Rebuild the whole app from the current plugin set and **reassign the variable**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
|
||||||
|
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
|
||||||
|
|
||||||
|
// install: app = buildApp([...installed, 'offscale'])
|
||||||
|
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
|
||||||
|
```
|
||||||
|
|
||||||
|
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
|
||||||
|
|
||||||
|
```
|
||||||
|
no plugins /offscale/x -> 404 | /core -> 200
|
||||||
|
installed /offscale/x -> 200 | /core -> 200
|
||||||
|
uninstalled /offscale/x -> 404 | /core -> 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
|
||||||
|
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
|
||||||
|
|
||||||
|
### The one line that has to change
|
||||||
|
|
||||||
|
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
|
||||||
|
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
|
||||||
|
This is the whole mechanical cost.
|
||||||
|
|
||||||
|
### Websockets are a separate table, and they reload
|
||||||
|
|
||||||
|
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
|
||||||
|
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
|
||||||
|
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
|
||||||
|
|
||||||
|
```
|
||||||
|
before reload /api/offscale/ws -> refused | /core -> 200
|
||||||
|
after reload /api/offscale/ws -> CONNECTED | /core -> 200
|
||||||
|
after remove /api/offscale/ws -> refused | /core -> 200
|
||||||
|
```
|
||||||
|
|
||||||
|
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
|
||||||
|
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
|
||||||
|
|
||||||
|
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
|
||||||
|
before a plugin install can interrupt somebody's terminal.
|
||||||
|
|
||||||
|
The two tables remain two lists, which is the same seam as the totality bug below.
|
||||||
|
|
||||||
|
### What this means for `assertCapabilityTotality`
|
||||||
|
|
||||||
|
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
|
||||||
|
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
|
||||||
|
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
|
||||||
|
where mounting actually happens instead of once at start-up.
|
||||||
|
|
||||||
|
Two things it must survive, both live today:
|
||||||
|
|
||||||
|
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
|
||||||
|
uninstalled plugin's routes are not mounted, so nothing can reach them.
|
||||||
|
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
|
||||||
|
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
|
||||||
|
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
|
||||||
|
still needs pointing at reality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
|
||||||
|
|
||||||
|
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
|
||||||
|
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
|
||||||
|
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||||
|
platform's to assign.
|
||||||
|
|
||||||
|
### The platform grants read or write. Everything richer is the plugin's own job
|
||||||
|
|
||||||
|
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
|
||||||
|
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
|
||||||
|
the surface; `write` permits everything.
|
||||||
|
|
||||||
|
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
||||||
|
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
|
||||||
|
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
|
||||||
|
means; the platform only knows whether this account got through the door.
|
||||||
|
|
||||||
|
### Offscale v1 uses that model exactly, with nothing added
|
||||||
|
|
||||||
|
One shared resource, role-gated:
|
||||||
|
|
||||||
|
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
|
||||||
|
- **write** — can change them, including deleting a server the owner registered
|
||||||
|
|
||||||
|
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
|
||||||
|
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
|
||||||
|
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
|
||||||
|
use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||||
|
|
||||||
|
Two implementation consequences, both inside the plugin:
|
||||||
|
|
||||||
|
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||||
|
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||||
|
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||||
|
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||||
|
|
||||||
|
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
|
||||||
|
- `POST /ssh-test` — a reachability probe that mutates nothing
|
||||||
|
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
|
||||||
|
|
||||||
|
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
|
||||||
|
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
|
||||||
|
delete, policy `PUT` — is a genuine write.
|
||||||
|
|
||||||
|
### Music is where the richer model gets designed
|
||||||
|
|
||||||
|
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
|
||||||
|
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
|
||||||
|
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
|
||||||
|
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
|
||||||
|
where offscale's is just "the owner's".
|
||||||
|
|
||||||
|
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
|
||||||
|
|
||||||
|
### Three different things are called "capability" here
|
||||||
|
|
||||||
|
A manifest needs three names, not one:
|
||||||
|
|
||||||
|
1. `capabilities/registry.ts` — **permissions** (`headscale`, `vpn`)
|
||||||
|
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
|
||||||
|
3. `sidecar-registry` `capabilities: ['music']` — **routing keys** for `sendCommand`
|
||||||
|
|
||||||
|
Offscale needs (1) and (3), and not (2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Two stores, and a plugin author will reach for the wrong one unless told:
|
||||||
|
|
||||||
|
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
|
||||||
|
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
|
||||||
|
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
|
||||||
|
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
|
||||||
|
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
|
||||||
|
parties touch it.
|
||||||
|
|
||||||
|
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
|
||||||
|
the host:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db } from '../db'; // the connection
|
||||||
|
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
|
||||||
|
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
|
||||||
|
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
|
||||||
|
the pilot rather than being invented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `/api/vpn` is being deleted
|
||||||
|
|
||||||
|
Officer had two headscale surfaces:
|
||||||
|
|
||||||
|
| | `/api/vpn` | `/api/headscale` |
|
||||||
|
| ---------- | ---------------------------------------- | -------------------------------------- |
|
||||||
|
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
|
||||||
|
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||||
|
| surface | one route, `POST /enroll` | the whole admin API |
|
||||||
|
|
||||||
|
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
|
||||||
|
anywhere.** Verified against the mobile monorepo:
|
||||||
|
|
||||||
|
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
|
||||||
|
2. `enroll()` is reached only via `if (embedded) await enroll()`
|
||||||
|
3. `embedded` is optional and defaults to `false`
|
||||||
|
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
|
||||||
|
|
||||||
|
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
|
||||||
|
does the Officer web app. The live database holds no `vpn` grants.
|
||||||
|
|
||||||
|
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
|
||||||
|
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
|
||||||
|
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
|
||||||
|
|
||||||
|
### Everything collapses to one namespace
|
||||||
|
|
||||||
|
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
|
||||||
|
contract has no counterparty.
|
||||||
|
|
||||||
|
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
|
||||||
|
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
|
||||||
|
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
|
||||||
|
|
||||||
|
- **phone → Companion** — untouched by anything here
|
||||||
|
- **web admin → Officer → sidecar** — ours to rename freely
|
||||||
|
|
||||||
|
### There are THREE components, not two
|
||||||
|
|
||||||
|
Easy to miss, and worth stating because two of them contain the word "enroll":
|
||||||
|
|
||||||
|
| Component | Repo | Enrolment surface |
|
||||||
|
| ---------------- | ---------------------------- | ------------------------------------------------ |
|
||||||
|
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
|
||||||
|
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
|
||||||
|
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
|
||||||
|
|
||||||
|
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
|
||||||
|
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
|
||||||
|
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
|
||||||
|
|
||||||
|
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
|
||||||
|
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
|
||||||
|
in the invite (`https://<domain>/officer-api`).
|
||||||
|
|
||||||
|
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
|
||||||
|
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
|
||||||
|
`POST /_officer/enroll` handler below it is dead.
|
||||||
|
|
||||||
|
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
|
||||||
|
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
|
||||||
|
The exemption must be declared with a reason or the boot check refuses. Not needed today.
|
||||||
|
|
||||||
|
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
|
||||||
|
is correct. The invite flow supersedes it completely:
|
||||||
|
|
||||||
|
1. the Officer headscale app holds an admin API key for the Headscale server
|
||||||
|
2. from it the owner mints an **invite** — a URL pointing at the Companion
|
||||||
|
3. the Companion turns that into the redirect the phone app claims
|
||||||
|
4. the device joins
|
||||||
|
|
||||||
|
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
||||||
|
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
|
||||||
|
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What headscale actually is — the inventory
|
||||||
|
|
||||||
|
Read end to end on 2026-08-14. This is what has to move.
|
||||||
|
|
||||||
|
### Backend — 2,406 lines
|
||||||
|
|
||||||
|
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||||
|
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||||
|
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||||
|
|
||||||
|
Three things worth knowing before touching it:
|
||||||
|
|
||||||
|
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
|
||||||
|
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
|
||||||
|
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
|
||||||
|
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
|
||||||
|
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
|
||||||
|
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
|
||||||
|
object back. One file containing all of it is the model for a plugin's client layer, not something to
|
||||||
|
undo.
|
||||||
|
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
|
||||||
|
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
|
||||||
|
branches on whether the body parses.
|
||||||
|
|
||||||
|
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
|
||||||
|
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
|
||||||
|
|
||||||
|
### Frontend — 29 files, 27 endpoints
|
||||||
|
|
||||||
|
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||||
|
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||||
|
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||||
|
Diagnostics · Console.
|
||||||
|
|
||||||
|
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||||
|
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
|
||||||
|
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
|
||||||
|
|
||||||
|
The whole frontend↔host coupling, which becomes the plugin API:
|
||||||
|
|
||||||
|
| Import | Why it matters |
|
||||||
|
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
|
||||||
|
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||||
|
| `AppRegistryMeta` | the panel-contribution contract |
|
||||||
|
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
||||||
|
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
|
||||||
|
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||||
|
|
||||||
|
### `assist.ts` travels, but stays unwired
|
||||||
|
|
||||||
|
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||||
|
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||||
|
later. Do not tidy it away as unused code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The manifest — proposal
|
||||||
|
|
||||||
|
Written against offscale rather than invented in the abstract, on the principle that a field list designed
|
||||||
|
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
|
||||||
|
is the floor, not the ceiling.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// plugins/offscale/manifest.ts
|
||||||
|
export const manifest = {
|
||||||
|
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
|
||||||
|
publisher: 'officerdev',
|
||||||
|
/** The plugin's own semver. Updates compare against this. */
|
||||||
|
version: '1.0.0',
|
||||||
|
/** Which platforms this build is good for. Refused at install when it does not match. */
|
||||||
|
platform: '>=1.0.0 <2.0.0',
|
||||||
|
|
||||||
|
label: 'Offscale',
|
||||||
|
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
||||||
|
icon: 'Network',
|
||||||
|
color: '#818cf8',
|
||||||
|
|
||||||
|
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
|
||||||
|
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
|
||||||
|
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'offscale',
|
||||||
|
label: 'Offscale',
|
||||||
|
description: 'The tailnet: machines, routes and ACLs',
|
||||||
|
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
|
||||||
|
ownerOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### THE RULE: every plugin route renders a Workspace with at least one panel
|
||||||
|
|
||||||
|
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
|
||||||
|
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
|
||||||
|
|
||||||
|
```
|
||||||
|
web/panels.ts exports appRegistryMetas — at least one panel
|
||||||
|
web/layout.ts exports defaultLayout — how they are arranged
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
|
||||||
|
name and with the reason:
|
||||||
|
|
||||||
|
```
|
||||||
|
probeplug: has a web/ directory but is missing web/layout.ts.
|
||||||
|
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
|
||||||
|
```
|
||||||
|
|
||||||
|
There is deliberately no way to export a component. A plugin that could would be free to render a bare
|
||||||
|
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
|
||||||
|
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
|
||||||
|
is nowhere to put a screen.
|
||||||
|
|
||||||
|
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
|
||||||
|
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||||
|
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
|
||||||
|
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
|
||||||
|
than rendering another plugin's panel inside this one.
|
||||||
|
|
||||||
|
### Everything the tree can say, the tree says
|
||||||
|
|
||||||
|
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
|
||||||
|
a human chose. Everything structural is convention, and **presence is the declaration**:
|
||||||
|
|
||||||
|
| Path | Means |
|
||||||
|
| -------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
|
||||||
|
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
|
||||||
|
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
|
||||||
|
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
|
||||||
|
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
|
||||||
|
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
|
||||||
|
|
||||||
|
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
|
||||||
|
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
|
||||||
|
duplication that could only ever drift.
|
||||||
|
|
||||||
|
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
|
||||||
|
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
|
||||||
|
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
|
||||||
|
field that can contradict the file it describes.
|
||||||
|
|
||||||
|
### Install asks nothing, and that is the default
|
||||||
|
|
||||||
|
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
|
||||||
|
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
|
||||||
|
and no external service to point at.
|
||||||
|
|
||||||
|
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
|
||||||
|
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
|
||||||
|
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
|
||||||
|
how it works today.
|
||||||
|
|
||||||
|
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
|
||||||
|
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
|
||||||
|
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
|
||||||
|
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
|
||||||
|
|
||||||
|
### Dropped from the first draft
|
||||||
|
|
||||||
|
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
|
||||||
|
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
|
||||||
|
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
|
||||||
|
when something consumes it.
|
||||||
|
- **`kind`** — see below.
|
||||||
|
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
|
||||||
|
|
||||||
|
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
|
||||||
|
means a tile. Fine for offscale; add a flag the first time something needs it.
|
||||||
|
|
||||||
|
### `admin` has to be allowed, and the pilot proved it immediately
|
||||||
|
|
||||||
|
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
|
||||||
|
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
|
||||||
|
|
||||||
|
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
|
||||||
|
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
|
||||||
|
nothing is gained by forbidding it.
|
||||||
|
|
||||||
|
Corrected rule:
|
||||||
|
|
||||||
|
| Kind | May a plugin declare it? | Why |
|
||||||
|
| ----------- | ------------------------ | ---------------------------------------------------------- |
|
||||||
|
| `app` | yes | the ordinary grantable surface |
|
||||||
|
| `admin` | yes | self-restriction, never an escalation |
|
||||||
|
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
|
||||||
|
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
|
||||||
|
| `confined` | **no** | implies a Linux identity the platform provisions |
|
||||||
|
|
||||||
|
### One function decides the prefix
|
||||||
|
|
||||||
|
`publisher` is the only input, so first-party and third-party cannot become two code paths:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const mountPrefix = (m: Manifest) =>
|
||||||
|
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
|
||||||
|
|
||||||
|
### Notes on the fields
|
||||||
|
|
||||||
|
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
|
||||||
|
everything else is bun. One plugin already needs it, so it is not speculative generality.
|
||||||
|
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
|
||||||
|
1.0 is expected before anyone outside Officer Dev writes a plugin.
|
||||||
|
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
|
||||||
|
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
|
||||||
|
degrade. This is for the human reading the store.
|
||||||
|
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
|
||||||
|
- **No `migrations`.** Deferred; a field can be added without redesign.
|
||||||
|
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is built — complete, as of 2026-08-15
|
||||||
|
|
||||||
|
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
|
||||||
|
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
|
||||||
|
time.
|
||||||
|
|
||||||
|
| Piece | Where |
|
||||||
|
| --------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||||
|
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||||
|
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||||
|
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
|
||||||
|
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
|
||||||
|
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
||||||
|
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
||||||
|
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||||
|
| Capability registration | `capabilities/registry.ts` → `setPluginCapabilities` |
|
||||||
|
| Install state | `plugin_installs` |
|
||||||
|
| The screen | `/plugins`, two panels, SSE log |
|
||||||
|
| The reference plugin | `plugins/example/` |
|
||||||
|
| **The first real plugin** | `plugins/offscale/` — 45 files |
|
||||||
|
|
||||||
|
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
|
||||||
|
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
|
||||||
|
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||||
|
|
||||||
|
### Three bugs the extraction found
|
||||||
|
|
||||||
|
Worth recording because none were visible from reading:
|
||||||
|
|
||||||
|
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
|
||||||
|
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
|
||||||
|
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
|
||||||
|
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
|
||||||
|
has no listener to announce. Install and enable now mount first.
|
||||||
|
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
|
||||||
|
applies to Bun's static serving and not to a programmatic `Bun.build()`.
|
||||||
|
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
|
||||||
|
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
|
||||||
|
|
||||||
|
### Still open
|
||||||
|
|
||||||
|
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||||
|
still the hardcoded providers. No plugin owns a socket yet.
|
||||||
|
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
|
||||||
|
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
|
||||||
|
`buildHonoApp`, which is now the single place routes are mounted.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
|
||||||
|
plugin system. One when the app store is rebuilt on this.
|
||||||
|
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
|
||||||
|
OWNER's rows rather than the caller's, which is a change inside the plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The state of the app store, as found
|
||||||
|
|
||||||
|
It **is** the plugin system, roughly 90% built, with one structural hole.
|
||||||
|
|
||||||
|
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
|
||||||
|
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
|
||||||
|
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
|
||||||
|
|
||||||
|
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
|
||||||
|
> and it is not built."_
|
||||||
|
|
||||||
|
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
|
||||||
|
written, assets publish, the dock tile appears — and the sidecar never starts.
|
||||||
|
|
||||||
|
Also found:
|
||||||
|
|
||||||
|
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
|
||||||
|
`bun db:push`.
|
||||||
|
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
|
||||||
|
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
|
||||||
|
that the directory exists.
|
||||||
|
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
|
||||||
|
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
|
||||||
|
means editing two files and rebuilding.
|
||||||
|
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
|
||||||
|
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
|
||||||
|
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
|
||||||
|
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
|
||||||
|
renders against it. Not to be confused with any of the above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where the code lives
|
||||||
|
|
||||||
|
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
|
||||||
|
|
||||||
|
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
|
||||||
|
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
|
||||||
|
used in addition rather than instead — they span orgs, which matters because browser extensions under
|
||||||
|
`extensions/` may become plugins later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
|
||||||
|
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
|
||||||
|
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
|
||||||
|
platform checkout, so dev-time and build-time are the same mechanism.
|
||||||
|
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
|
||||||
|
something has to apply schema changes over time. Cheap now, miserable to retrofit.
|
||||||
|
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
|
||||||
|
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
|
||||||
|
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
|
||||||
|
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
|
||||||
|
future, not before.
|
||||||
|
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
|
||||||
|
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
|
||||||
|
→ `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
|
||||||
|
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
|
||||||
|
Console section does not travel with the plugin.
|
||||||
|
5. **`service_connections.service` namespacing** before third parties touch it.
|
||||||
|
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
|
||||||
|
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
|
||||||
|
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
|
||||||
|
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
|
||||||
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||||
// this file must never grow app logic.
|
// this file must never grow app logic.
|
||||||
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
|||||||
|
|
||||||
const proxy = createSidecarProxy({
|
const proxy = createSidecarProxy({
|
||||||
name: 'headscale',
|
name: 'headscale',
|
||||||
prefix: '/api/headscale',
|
prefix: '/api/offscale',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const headscaleRouter = proxy.router;
|
export const router = proxy.router;
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { headscaleServers } from './schema';
|
import { headscaleServers } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from '../crypto';
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||||
|
|
||||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from '../auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Offscale — Headscale, plus the Companion that ships beside it.
|
||||||
|
//
|
||||||
|
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
|
||||||
|
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
|
||||||
|
// distinct product rather than a badge on someone else's.
|
||||||
|
//
|
||||||
|
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
|
||||||
|
//
|
||||||
|
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
|
||||||
|
// sidecar/ the whole Headscale contract, holding the admin API keys
|
||||||
|
// db/ offscale_servers, and the only table this plugin owns
|
||||||
|
// web/ panels and a layout; the shell renders the Workspace
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Offscale',
|
||||||
|
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
|
||||||
|
icon: 'Network',
|
||||||
|
color: '#818cf8',
|
||||||
|
|
||||||
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
|
//
|
||||||
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||||
|
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
||||||
|
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
||||||
|
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
||||||
|
// That is a change inside these queries, not a flag on the manifest.
|
||||||
|
//
|
||||||
|
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||||
|
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||||
|
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'offscale',
|
||||||
|
label: 'Offscale',
|
||||||
|
description: 'The tailnet: machines, routes, keys and ACLs',
|
||||||
|
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
|
||||||
|
// Without declaring them a read-level account meets a broken feature where a withheld permission
|
||||||
|
// should be. Inert while ownerOnly, and correct the moment that changes.
|
||||||
|
readOnlyWrites: ['/ssh-test', '/policy/assist'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
|
|
||||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
||||||
//
|
//
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||||
|
|
||||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||||
// wire-level quirks are handled once:
|
// wire-level quirks are handled once:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
||||||
|
|
||||||
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OfficerContext } from './routes';
|
import type { OfficerContext } from './routes';
|
||||||
import type { OfficerUser } from './normalize';
|
import type { OfficerUser } from './normalize';
|
||||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
import { arrayField, toUser } from './normalize';
|
import { arrayField, toUser } from './normalize';
|
||||||
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
|
|||||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||||
//
|
//
|
||||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
|
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||||
|
// HEADSCALE_URL, HEADSCALE_API_KEY
|
||||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { handleOfficerRoute } from './routes';
|
import { handleOfficerRoute } from './routes';
|
||||||
import { MIN_VERSION_LABEL } from './version';
|
import { MIN_VERSION_LABEL } from './version';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||||
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// DELETE /_officer/keys/:id delete outright
|
// DELETE /_officer/keys/:id delete outright
|
||||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||||
// for a joining device. userId is only required when the server
|
// for a joining device. userId is only required when the server
|
||||||
// has more than one user; reached via /api/vpn/enroll.
|
// has more than one user.
|
||||||
|
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||||
|
// which is deleted. Kept because it is the handler a route under
|
||||||
|
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||||
|
// IS live — dispatches through the same function.
|
||||||
// anything else 404
|
// anything else 404
|
||||||
//
|
//
|
||||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||||
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
function getFreePort(): number {
|
function getFreePort(): number {
|
||||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
@@ -118,7 +121,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'headscale',
|
name: 'headscale',
|
||||||
capabilities: ['headscale'],
|
handles: ['headscale'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
deleteHeadscaleServer,
|
deleteHeadscaleServer,
|
||||||
getHeadscaleCredentials,
|
getHeadscaleCredentials,
|
||||||
recordHeadscaleProbe,
|
recordHeadscaleProbe,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { createClient, HeadscaleError } from './client';
|
import { createClient, HeadscaleError } from './client';
|
||||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
|||||||
import { Loader2, TerminalSquare } from 'lucide-react';
|
import { Loader2, TerminalSquare } from 'lucide-react';
|
||||||
import { headscaleSectionPath } from './shared';
|
import { headscaleSectionPath } from './shared';
|
||||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||||
import { TerminalView } from '../Terminal/Terminal';
|
import { TerminalView } from 'officerdev';
|
||||||
import { Button } from './Cards';
|
import { Button } from './Cards';
|
||||||
|
|
||||||
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
|
|||||||
|
|
||||||
export const defaultLayout: LayoutNode = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'headscale-root',
|
id: 'offscale-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
||||||
import { HeadscaleNav } from './HeadscaleNav';
|
import { HeadscaleNav } from './HeadscaleNav';
|
||||||
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
|
|||||||
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
|
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
|
||||||
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
||||||
|
|
||||||
const BASE = '/headscale/_officer/companion';
|
const BASE = '/offscale/_officer/companion';
|
||||||
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+15
-18
@@ -24,28 +24,26 @@ export function useHeadscaleNodes() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: NODES_KEY,
|
queryKey: NODES_KEY,
|
||||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'),
|
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
|
||||||
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
||||||
refetchInterval: 20_000,
|
refetchInterval: 20_000,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rename = useMutation({
|
const rename = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
|
||||||
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setTags = useMutation({
|
const setTags = useMutation({
|
||||||
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
|
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
|
||||||
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
||||||
const moveToUser = useMutation({
|
const moveToUser = useMutation({
|
||||||
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
||||||
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
|
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,17 +51,17 @@ export function useHeadscaleNodes() {
|
|||||||
// because Headscale's approve_routes replaces the whole set.
|
// because Headscale's approve_routes replaces the whole set.
|
||||||
const toggleRoute = useMutation({
|
const toggleRoute = useMutation({
|
||||||
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
|
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
|
||||||
post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }),
|
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expire = useMutation({
|
const expire = useMutation({
|
||||||
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
|
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,24 +85,23 @@ export function useHeadscaleUsers() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: USERS_KEY,
|
queryKey: USERS_KEY,
|
||||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
|
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
|
||||||
post('/headscale/_officer/users', input),
|
post('/offscale/_officer/users', input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rename = useMutation({
|
const rename = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
|
||||||
post(`/headscale/_officer/users/${id}/rename`, { name }),
|
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +130,7 @@ export function useHeadscaleKeys() {
|
|||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: KEYS_KEY,
|
queryKey: KEYS_KEY,
|
||||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
|
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,17 +138,17 @@ export function useHeadscaleKeys() {
|
|||||||
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (input: CreateKeyInput) =>
|
mutationFn: (input: CreateKeyInput) =>
|
||||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
|
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expire = useMutation({
|
const expire = useMutation({
|
||||||
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
|
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
|
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
+1
-1
@@ -10,7 +10,7 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
|
|||||||
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
|
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
|
||||||
// drops it. The list is refetched instead, which returns the same invite without its token.
|
// drops it. The list is refetched instead, which returns the same invite without its token.
|
||||||
|
|
||||||
const BASE = '/headscale/_officer/enroll/invites';
|
const BASE = '/offscale/_officer/enroll/invites';
|
||||||
const INVITES_KEY = ['headscale', 'invites'] as const;
|
const INVITES_KEY = ['headscale', 'invites'] as const;
|
||||||
|
|
||||||
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
|
|||||||
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
||||||
|
|
||||||
const POLICY_KEY = ['headscale', 'policy'] as const;
|
const POLICY_KEY = ['headscale', 'policy'] as const;
|
||||||
const PATH = '/headscale/_officer/policy';
|
const PATH = '/offscale/_officer/policy';
|
||||||
|
|
||||||
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||||
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user