Commit Graph
7 Commits
Author SHA1 Message Date
pastilhas 7f8455f1af user management keyed (267 -> 214)
Accounts, permissions and the create-user form. The confirm dialogs for removing an
account and resetting a password were multi-line JSX the scanner cannot see, so they
were converted on sight rather than from its list.

Cancel comes from ui.button.cancel in all four places it appears here.
2026-08-23 05:10:17 +00:00
pastilhas 9c2d6a97f7 a tested migration script for the capabilities→permissions rename
Other machines have to make the same database change, and pasted SQL is the
wrong way to ship it. `scripts/rename-capabilities-to-permissions.ts` renames
the table, its column, both indexes and its three CHECK constraints in one
transaction, and refuses to guess:

  neither table        nothing to do; db:push will create it
  already renamed      no-op, prints the grant count
  BOTH tables present  stops and says a human must decide
  old table only       migrates, counts before and after, prints every grant

Testing it found a bug that reading it had not. The existence checks were
inside the transaction but ran on the pool rather than on `tx`, so they could
not see the uncommitted rename: `columnExists('role_permissions','capability')`
answered false because that table did not exist yet on that connection, and the
COLUMN rename was silently skipped. The result was a `role_permissions` table
with a `capability` column — half migrated, and only failing on the next query.

That was found by building a scratch database in edge-pertento's exact shape
and running the script against it, rather than by review. Every check now
happens before the transaction, against the old names.

Verified end to end on that scratch: 6 grants migrated intact, a second run
correctly no-ops, and `db:push` afterwards leaves role_permissions and its rows
alone while creating the rest of the schema. Indexes come out as
role_permissions_pkey and uq_role_permissions_role_permission.

Also swept the last all-caps survivors the case-sensitive passes missed:
CORE_CAPABILITIES → CORE_PERMISSIONS, and a react-query key still spelling
['ROLE_CAPABILITIES']. The only CAPABILITIES left in src/ is the wallet's, which
is Lightning and stays.
2026-08-15 16:40:18 +00:00
pastilhas 2e8ec845c8 step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
2026-08-15 16:03:22 +00:00
pastilhasandClaude Opus 5 f0af7237db terminal, chat and files are granted by default; permissions screen simplified
DEFAULTS. Every role now starts with the three confined capabilities at write, seeded in
bootstrap. These are what the platform is FOR — an account that signs in and reaches none of
them is not restricted, it is useless, and making the owner grant them by hand first is a
step with no decision in it.

Seeded as real rows rather than implied by absence, which keeps the table's one rule intact:
a missing row means no access, always, with no exception to remember. Revoking one therefore
works like revoking anything else — the row goes and nothing puts it back. Done in bootstrap
because that happens exactly once per install, so seeding can never fight a later revocation.
Non-fatal: an owner whose roles hold nothing is a one-click fix, while failing bootstrap over
it leaves a platform with no account at all.

`app` capabilities are deliberately not defaulted — they reach data the owner may not intend
to share, and each needs a sidecar before it means anything.

SCREEN. Role selection is tabs rather than a dropdown: three roles are the axis you move
along, and a select hid two of them behind a click while giving no sense of which one you are
editing. Row descriptions are gone — with three rows called Terminal, Chat and Files they
explained nothing — and the "needs a Linux account" warning went with them, since every
account now gets one at creation, so it was noise about a state that no longer occurs on its
own. `needsOsAccount` is removed from the API too, not just hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:25:31 +00:00
pastilhasandClaude Opus 5 eda004a46d a naked platform does not describe what it does not have
Reversing my own call from an hour ago. I built the denied-route screen to EXPLAIN the
absence — "Music is not installed", with a link to the app store — and argued a redirect
erases what you asked for. The owner's correction is the better principle: a server should
not know about a sidecar it does not have. Explaining Music is the app describing a feature
that, as far as this install is concerned, does not exist, and it leaks the whole catalogue
of what could be installed to any member who types a URL.

So a denied path is now indistinguishable from an unknown one: redirect home, the same
answer App.tsx's path="*" already gave. One behaviour for a member without a grant, an
owner without the sidecar, and a typo. Nothing disclosed.

The Permissions screen loses both explanatory blocks for the same reason. One listed every
capability whose sidecar is absent — a catalogue of uninstallable features presented as a
permissions decision. The other described chat, tasks, the desktop and the wallet as
"not grantable" to an owner who may have none of them installed. `notInstalled` is gone
from the API too, not just hidden in the UI. What is on that screen is what this server can
actually do.

Still short of what the owner described, and worth naming rather than implying otherwise:
routes are DECLARED in App.tsx for every screen and this hides the ones that should not
resolve. The end state is routes REGISTERED from the manifests of installed sidecars, so an
uninstalled feature has no route to hide. The manifests already exist and the dock is
already built from them; the router is not, yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:51:41 +00:00
pastilhasandClaude Opus 5 e393d0f5c2 a member's screens render, and the shell stops asking for things it cannot have
Three findings from granting Files to a role and signing in as the member.

THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded
was the success flag of GET /api/dashboards — which the `dashboards` capability gated.
So a member with files granted got a completely blank Files screen and no request to
/api/file-browser at all: the panel never mounted. Terminal, Chat and every other
workspace screen were the same.

/api/dashboards is not a feature. It is the per-user key-value store where every
screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it
does not restrict an account, it breaks it — which is the definition of `core` at the
top of the registry. Moved there.

And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as
well as a successful one, with `loadFailed` for the difference, so a screen that cannot
remember its layout still renders with defaults instead of showing nothing and
explaining nothing.

THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on
capability, so a member's first paint fired 403s at /server-settings/settings,
/jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing
and the chat access policy. Each now checks the capability it needs. JobsIndicator and
RescanButton also render nothing without `tasks` and `items` — the header was offering
two links to a screen the member cannot open and a button that would 403.

THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none
of their sidecars are installed. Offering to grant Photos on a machine with no Immich
is not a permission decision. It now shows only what is installed, lists the rest as
"nothing installed for these yet" so their absence reads as a fact rather than a bug,
and marks confined rows as needing a Linux account. Fails open on a degraded read.

Found while checking that: the headscale catalogue entry claimed only the `headscale`
capability, but the same sidecar also serves `vpn` — a member enrolling their own
device — so vpn was never subtracted. Hence `alsoServes`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:13:03 +00:00
pastilhasandClaude Opus 5 537a77d320 capabilities: the dock a member sees, and the screen the owner grants from
useCapabilities is the frontend's view of the model and explicitly NOT its
enforcement — hiding a dock icon is a courtesy, the 403 in origin-validation
is the lock. so it fails OPEN: if the request errors the full dock renders.
a member clicking through to a 403 is a bad minute; an owner locked out of
their own platform by a transient network error is an incident, and the
server refuses what it should refuse either way.

the endpoint returns held routes AND denied routes, because absence from the
held list cannot distinguish a route this account lacks from one no
capability claims at all — `/`, the settings shell — and a guard that cannot
tell those apart either blanks the app or guards nothing. i wrote the first
version without the second list and it silently permitted everything.

`can` and `canVisit` are memoised on the query data. a verb rebuilt every
render gets a new identity every render, which is how every playback report
in the jellyfin player was disabled for days; the dock filter puts one in a
useMemo dependency list, so it would have been the same bug.

the permissions screen is one role at a time, with an explicit save and a
dirty state, rather than a roles-by-capabilities grid — a grid invites
reading across rows, which is not a question anyone has, and makes revoking
gitea for every member one click among fifty. it also states plainly why
terminal, chat, files and the rest are absent, so their absence reads as a
decision rather than as a missing feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 01:00:47 +00:00