vault, extracted into plugins/vault — the platform half
Fourth extraction, after offscale, music and email. Everything vault is now at
gitea.officer.dev/plugins/vault and installs from the marketplace; this commit
is what the platform stopped knowing.
Deleted, not commented: both routers, the sidecar, the db directory, the
hono.ts mounts/imports, server.tsx's ws import + handler entry + four
route-table entries, '/vault' from UNPROTECTED_API_PREFIXES, both entries in
totality.ts's exemption lists, both database barrels, and the app-store
catalogue entry.
The catalogue deletion is required rather than tidying — the trap the runbook
records as having bitten twice. permissionAvailability() derives from
sidecar_installs, which a plugin never gets a row in, so a leftover entry makes
the permission permanently 'unavailable' and withholds the route and dock tile
on a server where the plugin is installed and working.
── The one new piece of platform: src/servers/account-lifecycle.ts ──
Three auth handlers (signout, distress, panic) imported clearVaultTokens and
clearVaultUnlockKey directly. That is the security property behind the distress
and panic buttons — "someone got in, make the vault need a master password
again" — so it could not just be dropped, and a plugin cannot be imported by
core.
Rejected: having core DELETE from the plugin's tables by name. It removes the
import but moves the coupling somewhere worse — core would depend on a plugin's
SCHEMA, so renaming a column would silently stop the wipe. A security property
that fails silently on an unrelated change is the failure mode to design
against. (docs/email-plugin-extraction.md took that shape for READING a
plugin's data, where a stale answer is a missing feature rather than a lock
left open. The stakes decide it, not the pattern.)
Taken instead: core announces 'signout' | 'distress' | 'panic' with a user id,
and anything holding per-account state subscribes. ~30 lines, fire-and-forget,
one listener's failure cannot stop another. Core names no plugin, no table and
no column; a listener that is not there is a plugin that is not installed. The
severity mapping moved into the plugin — signout keeps the protector key,
distress and panic drop it — so the behaviour is unchanged.
── Two things deleted rather than carried, both dark since 2026-08-13 ──
The unauthenticated Bitwarden passthrough (/vaultwarden plus four root
prefixes and an /api/* header diverter). A plugin's routes mount into
protectedRouter at /api/<name>, behind the account gate; top-level
unauthenticated prefixes are not something a manifest can declare, and the Bun
route table they need entries in is built once at serve(). Keeping it meant
core naming /vaultwarden and /identity in its own route table — the vocabulary
leak the runbook forbids, for a path its own comments called TEMPORARY pending
a first-party browser extension. Owner's call: nice to have, not a requirement.
The notifications websocket, for the same reason plus a second: no plugin owns
a socket on this platform yet (server.reload({routes}) proven, never called;
handlers in server.tsx is a static map). Music parked cliamp/ for exactly this.
SignalR degrades to HTTP long-polling by itself, so clients still sync.
── Tests ──
Vault was the ONLY members: 'invite' catalogue entry, so deleting it would have
silently dropped coverage of that branch in plannedOutcome. It is now tested
against a synthetic entry instead: deleting an assertion along with the last
real user of a case is how a switch arm stops being covered without anyone
deciding it should.
bunx tsgo clean (verified it really does cover plugins/ by injecting a
deliberate error and watching it fail). Suite back at its 7 pre-existing
baseline failures — none of them this feature's, all confirmed unrelated
(the totality ones are about 'desktop'). 85 test files, unchanged, per the
runbook's trap about a moved test vanishing silently.
Needs `bun db:push` and a restart before installing, then point it at a
Vaultwarden from /vault — VAULTWARDEN_URL in .env is no longer read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,11 +112,10 @@ describe('member provisioning is declared for every entry', () => {
|
||||
for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none');
|
||||
});
|
||||
|
||||
it('marks the vault as invite-only, because it cannot be otherwise', () => {
|
||||
// Vaultwarden derives its encryption key from the master password. A credential we could mint is a
|
||||
// vault we could read, so 'accounts' here would be a security defect rather than a feature.
|
||||
expect(byId('vault')!.members).toBe('invite');
|
||||
});
|
||||
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
||||
// `plugins/vault/`. Its reasoning — a service that derives its encryption key from the user's own
|
||||
// password cannot have credentials minted for it — now belongs to that plugin, and `members.test.ts`
|
||||
// keeps the 'invite' branch itself covered.
|
||||
});
|
||||
|
||||
describe('the UI manifest each sidecar carries', () => {
|
||||
|
||||
@@ -229,27 +229,13 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
{ key: 'secret', label: 'API token', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vault',
|
||||
ui: { name: 'Vault', icon: 'KeyRound', color: '#175ddc', rootRoute: '/vault', routes: ['/vault'] },
|
||||
process: 'officer-vault',
|
||||
label: 'Vault',
|
||||
summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
|
||||
members: 'invite',
|
||||
modes: ['existing', 'provisioned'],
|
||||
// No permission entry exists for this one, and the reason is about CREDENTIALS, not routing.
|
||||
//
|
||||
// Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the
|
||||
// officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly.
|
||||
//
|
||||
// What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so
|
||||
// `userMiddleware` would 401 them and a permission lookup would have no account to resolve. Hence
|
||||
// `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead.
|
||||
// The install still governs whether the sidecar runs at all.
|
||||
permission: null,
|
||||
composeTemplate: 'vaultwarden',
|
||||
existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }],
|
||||
},
|
||||
// Vault was here until 2026-08-18, when it became `plugins/vault/`.
|
||||
//
|
||||
// Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having
|
||||
// bitten twice already. `permissionAvailability()` derives from `sidecar_installs`, and a plugin never
|
||||
// gets a row there (its install state is `plugin_installs`), so a leftover entry would make the
|
||||
// permission permanently `unavailable`, put its route into `deniedRoutes`, and withhold the dock tile —
|
||||
// on a server where the plugin is installed and working.
|
||||
{
|
||||
id: 'transmission',
|
||||
ui: {
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { plannedOutcome, servicesNeedingMemberProvision } from './members';
|
||||
import { byId } from './catalogue';
|
||||
import { byId, type CatalogueEntry } from './catalogue';
|
||||
|
||||
// `members: 'invite'` has no catalogue entry as of 2026-08-18 — the vault was the only one, and it left
|
||||
// to become `plugins/vault/`. The BRANCH is still live code any future entry can reach, so it is tested
|
||||
// against a synthetic entry rather than dropped: deleting the assertion with the last real user of a
|
||||
// case is how a switch arm stops being covered without anyone deciding that it should.
|
||||
const inviteOnly = { members: 'invite' } as CatalogueEntry;
|
||||
|
||||
describe('what adding a member would do', () => {
|
||||
it('predicts the outcome per service without contacting anything', () => {
|
||||
expect(plannedOutcome(byId('photos')!)).toBe('provisioned');
|
||||
expect(plannedOutcome(byId('vault')!)).toBe('invited');
|
||||
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
||||
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('which installed services have work to do', () => {
|
||||
it('skips single-tenant daemons entirely', () => {
|
||||
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'vault']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['photos', 'vault']);
|
||||
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'memos']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['memos', 'photos']);
|
||||
});
|
||||
|
||||
it('ignores services that are not installed', () => {
|
||||
|
||||
Reference in New Issue
Block a user