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:
2026-08-18 13:43:32 +00:00
co-authored by Claude Opus 5
parent c775e8c7e3
commit f6a1194905
27 changed files with 141 additions and 1150 deletions
-1
View File
@@ -51,7 +51,6 @@ export * from './jellyfin';
export * from './notify';
export * from './photos';
export * from './soulseek';
export * from './vault';
export * from './wallet';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
-1
View File
@@ -52,7 +52,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './jellyfin/schema'; // jellyfin_servers officer-jellyfin
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs
export * from './vault/schema'; // vault_tokens, vault_unlock_keys officer-vault
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
@@ -1,11 +0,0 @@
export {
getVaultTokens,
setVaultTokens,
updateVaultAccess,
clearVaultTokens,
getVaultUnlockKey,
setVaultUnlockKey,
clearVaultUnlockKey,
} from './queries';
export type { VaultTokenSet } from './queries';
@@ -1,101 +0,0 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { vaultTokens, vaultUnlockKeys } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Vault store access. Callers deal in PLAINTEXT — encryption to/from at-rest ciphertext happens here, so
// the token broker / proxy never touch the crypto directly. See ../crypto.ts and ../schema/vault.ts.
export type VaultTokenSet = {
accessToken: string;
refreshToken: string;
expiresAt: Date | null;
deviceIdentifier: string | null;
clientId: string | null;
};
/** The owner's brokered Vaultwarden token set (decrypted), or null if none is stored. */
export async function getVaultTokens(userId: number): Promise<VaultTokenSet | null> {
const [row] = await db.select().from(vaultTokens).where(eq(vaultTokens.userId, userId));
if (!row) return null;
return {
accessToken: decryptSecret('vault', row.accessToken),
refreshToken: decryptSecret('vault', row.refreshToken),
expiresAt: row.expiresAt,
deviceIdentifier: row.deviceIdentifier,
clientId: row.clientId,
};
}
/** Upsert the owner's token set (one row per account). Secrets are encrypted before write. */
export async function setVaultTokens(userId: number, t: VaultTokenSet): Promise<void> {
const values = {
userId,
accessToken: encryptSecret('vault', t.accessToken),
refreshToken: encryptSecret('vault', t.refreshToken),
expiresAt: t.expiresAt,
deviceIdentifier: t.deviceIdentifier,
clientId: t.clientId,
updatedAt: new Date(),
};
await db
.insert(vaultTokens)
.values(values)
.onConflictDoUpdate({
target: vaultTokens.userId,
set: {
accessToken: values.accessToken,
refreshToken: values.refreshToken,
expiresAt: values.expiresAt,
deviceIdentifier: values.deviceIdentifier,
clientId: values.clientId,
updatedAt: values.updatedAt,
},
});
}
/** Update only the token pair + expiry after a refresh (keeps device/client). */
export async function updateVaultAccess(
userId: number,
accessToken: string,
refreshToken: string,
expiresAt: Date | null,
): Promise<void> {
await db
.update(vaultTokens)
.set({
accessToken: encryptSecret('vault', accessToken),
refreshToken: encryptSecret('vault', refreshToken),
expiresAt,
updatedAt: new Date(),
})
.where(eq(vaultTokens.userId, userId));
}
/** Drop the token set (platform logout / distress / panic). */
export async function clearVaultTokens(userId: number): Promise<void> {
await db.delete(vaultTokens).where(eq(vaultTokens.userId, userId));
}
/** The owner's stored protector key (decrypted), or null. Officer-app unlock path only. */
export async function getVaultUnlockKey(userId: number): Promise<string | null> {
const [row] = await db.select().from(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId));
return row ? decryptSecret('vault', row.wrappedKey) : null;
}
/** Store/replace the protector key (encrypted). Set once at setup; persists across normal logout. */
export async function setVaultUnlockKey(userId: number, wrappedKey: string): Promise<void> {
const values = { userId, wrappedKey: encryptSecret('vault', wrappedKey), updatedAt: new Date() };
await db
.insert(vaultUnlockKeys)
.values(values)
.onConflictDoUpdate({
target: vaultUnlockKeys.userId,
set: { wrappedKey: values.wrappedKey, updatedAt: values.updatedAt },
});
}
/** Wipe the protector key (distress / panic → forces a one-time master-password re-setup). */
export async function clearVaultUnlockKey(userId: number): Promise<void> {
await db.delete(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId));
}
@@ -1,32 +0,0 @@
import { pgTable, integer, text, timestamp } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
// Officer Vault server-side state (see VAULT_AUTH_SPEC.md). The device never holds a Vaultwarden token;
// the platform brokers it, stores it here tied to the owner account, and injects it on proxied /api/vault
// requests. All secret columns are AES-256-GCM encrypted at rest (see ../crypto.ts).
// The brokered Vaultwarden token set. One row per owner account (single active vault session). Dropped on
// platform logout / distress / panic.
export const vaultTokens = pgTable('vault_tokens', {
userId: integer('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
accessToken: text('access_token').notNull(), // encrypted
refreshToken: text('refresh_token').notNull(), // encrypted
expiresAt: timestamp('expires_at', { withTimezone: true }),
deviceIdentifier: text('device_identifier'), // plaintext; the last device that brokered a login
clientId: text('client_id'), // plaintext; the connect/token client_id, needed to refresh
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
// The platform-held "protector key" for the Officer-app unlock path: it unwraps the on-device wrapped user
// key, so a valid platform session opens the vault without the master password. Persists across normal
// logout (frictionless re-login); wiped on distress/panic (forces a one-time master-password re-setup).
// The zero-knowledge OffVault path never writes here (its protector stays in the Secure Enclave).
export const vaultUnlockKeys = pgTable('vault_unlock_keys', {
userId: integer('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
wrappedKey: text('wrapped_key').notNull(), // encrypted (the protector key)
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
+1 -21
View File
@@ -18,7 +18,6 @@ import { chatWebsocket } from './servers/api/chat/websocket';
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
// import { desktopWebsocket } from './servers/api/desktop/websocket';
import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket';
import officerWeb from './apps/officer-web/index.gen.html';
// import { startBrowserRelay } from './servers/api/browser/relay'; // switched off — see below
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
@@ -52,7 +51,7 @@ type WSData = {
userId: number;
email: string;
username: string;
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop' | 'vault' | 'sidecar';
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop' | 'sidecar';
sessionId?: string;
cwd?: string;
command?: string;
@@ -144,7 +143,6 @@ const handlers: Record<string, any> = {
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
// desktop: desktopWebsocket,
vault: vaultWebsocket,
sidecar: sidecarWebsocket,
};
@@ -405,12 +403,6 @@ const server = serve({
if (!(await file.exists())) return new Response('Not found', { status: 404 });
return new Response(file);
},
// Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
'/api/vault/notifications/*': (req, server) => {
if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(req, server);
return honoServer.fetch(req, server);
},
// A dev server's HMR socket, framed by the Preview panel. Must be in the route table rather than
// behind the `/api/*` catch-all: Bun matches this table first, and an upgrade that reaches Hono is
// an ordinary GET by then.
@@ -437,18 +429,6 @@ const server = serve({
// '/.well-known/carddav': honoServer.fetch,
// '/dav': honoServer.fetch,
// '/dav/*': honoServer.fetch,
// Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own
// entry for the same reason /dav does: only the paths listed here reach hono, and anything else
// falls through to the SPA — which answers 200 with the React shell, so a missing line here looks
// like a working endpoint returning nonsense rather than a 404.
'/vaultwarden/*': honoServer.fetch,
// The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes
// are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing.
// `/api/*` already reaches hono below, where a Bitwarden client header diverts it.
'/identity/*': honoServer.fetch,
'/notifications/*': honoServer.fetch,
'/icons/*': honoServer.fetch,
'/events/*': honoServer.fetch,
...spaRoutes,
'/api': honoServer.fetch,
// A CLOSURE, deliberately, and not the bound `honoServer.fetch`.
+79
View File
@@ -0,0 +1,79 @@
// "This account's session ended" — announced by core, listened to by whoever holds per-account state.
//
// ── Why this exists ──
//
// Signout, distress and panic all have to reach state the PLATFORM does not own. The vault is the first
// case and the reason this file was written (2026-08-18, during its extraction into `plugins/vault/`):
// distress and panic wipe the brokered vault session and its protector key, which is the entire point of
// those buttons — "someone got in, make the vault need a master password again". Before the extraction,
// `auth/signout.ts`, `auth/revoke-handler.ts` and `auth/panic-handler.ts` imported `clearVaultTokens`
// straight from `officerdb` and called it.
//
// A plugin cannot be imported by core, so that had to end. The two alternatives were both worse:
//
// • **Drop the wipe.** A silent security regression, and precisely on the path that exists for the
// worst day. Not a trade a refactor gets to make by itself.
// • **Have core DELETE from the plugin's tables by name.** Removes the import but keeps the coupling
// and moves it 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, not accept. (This is the shape `docs/email-plugin-extraction.md`
// took 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.)
//
// So: core announces, and anything holding per-account state subscribes. Core names no plugin, no table
// and no column, and a listener that is not there is a plugin that is not installed.
//
// ── What it is deliberately not ──
//
// Not a general event bus, and it should not grow into one. Three events, all meaning "a session ended
// and here is how badly" — anything that is not about an account's session ending belongs elsewhere.
//
// Fire-and-forget by design. The caller is an auth route answering a person who pressed a button, and it
// must not fail, hang or wait because a listener did. A listener that throws is logged and skipped.
export type AccountLifecycleEvent =
/** Ordinary logout. The mildest: drop live sessions, keep anything that makes signing back in easy. */
| 'signout'
/** Distress — "someone got in". Drop everything reconstructible, including convenience material. */
| 'distress'
/** Panic lockdown. Same severity as distress; the server also stops answering until it is restarted. */
| 'panic';
type Listener = (userId: number, event: AccountLifecycleEvent) => void | Promise<void>;
const listeners = new Set<Listener>();
/**
* Subscribe. Returns an unsubscribe function.
*
* Register from a module the plugin system already imports — a plugin's `api/router.ts` is imported once
* when its routes are mounted, which makes the subscription's lifetime the same as the plugin being
* installed and enabled.
*/
export function onAccountLifecycle(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/**
* Announce. Never throws, never awaited by the caller, and one listener failing cannot stop another.
*
* Deliberately not `await`ed at the call sites: signout returning 200 must not depend on every listener
* having finished, and a listener that hangs must not hang the response. The cost is that a wipe is
* ordered rather than confirmed — acceptable because the token it invalidates is already unusable the
* moment the session it belonged to is blacklisted, which the same handler does synchronously.
*/
export function emitAccountLifecycle(userId: number, event: AccountLifecycleEvent): void {
for (const listener of listeners) {
try {
const result = listener(userId, event);
if (result instanceof Promise) {
result.catch((err: unknown) => {
console.error(`[account-lifecycle] ${event} listener failed:`, err instanceof Error ? err.message : err);
});
}
} catch (err) {
console.error(`[account-lifecycle] ${event} listener threw:`, err instanceof Error ? err.message : err);
}
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
// The role is what makes this account the owner — isSuperAdmin and getOwnerUser both read it, and
// nothing else confers it. Without this the first account would take the column's 'Member' default
// and the platform would come up with no owner at all: no vault, no agent identity, and the web
// and the platform would come up with no owner at all: no owner-only surfaces, no agent identity, and the web
// origin locked to a Super Admin that does not exist. Bootstrap is gated on an empty user table
// above, so this cannot promote anyone but the first account.
const user = await createUser({
+3 -6
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
import { triggerLockdown } from './panic';
import { clientIp } from './client-ip';
@@ -10,11 +10,8 @@ export const panicHandler: Handler = async (ctx) => {
const origin = ctx.get('origin') as string | undefined;
triggerLockdown(`/panic (user=${user?.email || '?'}, origin=${origin || '-'}, ip=${clientIp(ctx)})`);
// Panic wipes the vault session AND the protector key (like distress).
if (user?.id) {
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
}
// Panic is distress plus a locked-down server, so listeners get the same severity.
if (user?.id) emitAccountLifecycle(user.id, 'panic');
return ctx.json({ ok: true });
};
+6 -5
View File
@@ -1,5 +1,6 @@
import type { Handler } from 'hono';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
import { clientIp } from './client-ip';
// Distress: blacklist the current token (same as signout) but log it as a security event.
@@ -13,10 +14,10 @@ export const revokeHandler: Handler = async (ctx) => {
await blacklistToken(user.jti, user.exp);
// Distress wipes the vault session AND the stored protector key — forces a one-time master-password
// re-setup, the correct "someone got in" behavior.
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
// Distress means "someone got in": listeners drop everything reconstructible, not just live sessions.
// Deliberately harsher than signout — convenience material goes too, even where that costs the owner a
// one-time re-setup on their next login.
emitAccountLifecycle(user.id, 'distress');
cleanupExpiredTokens().catch(() => {});
+5 -3
View File
@@ -1,6 +1,7 @@
import type { Handler } from 'hono';
import type { AuthIdentity } from '@@/auth-token';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { emitAccountLifecycle } from '@@/account-lifecycle';
export const signoutHandler: Handler = async (ctx) => {
const user = ctx.get('user') as AuthIdentity;
@@ -9,8 +10,9 @@ export const signoutHandler: Handler = async (ctx) => {
// the keys API, which is the point of holding one instead of a session.
if (user.jti && user.exp) await blacklistToken(user.jti, user.exp);
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
clearVaultTokens(user.id).catch(() => {});
// Tell whatever holds per-account session state that this one ended. The mildest of the three levels:
// a listener should drop live sessions and keep whatever makes signing back in painless.
emitAccountLifecycle(user.id, 'signout');
// Opportunistic cleanup of expired tokens (non-blocking)
cleanupExpiredTokens().catch(() => {});
-79
View File
@@ -1,79 +0,0 @@
import { getVaultServerUrl } from './sidecar-server';
// Vaultwarden token broker. The platform (not the device) obtains and refreshes the Vaultwarden token via
// the sidecar's `/identity/connect/token`. We deal in the OAuth-style form grants only; the master
// password never appears here — the client sends a pre-derived `authHash` (Bitwarden's master password
// hash), exactly what Vaultwarden itself checks.
export type ConnectResult = { ok: true; data: Record<string, unknown> } | { ok: false; status: number; error: string };
async function postConnectToken(form: URLSearchParams): Promise<ConnectResult> {
const base = getVaultServerUrl();
if (!base) return { ok: false, status: 503, error: 'vault sidecar unavailable' };
let res: Response;
try {
res = await fetch(`${base}/identity/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
signal: AbortSignal.timeout(30_000),
});
} catch {
return { ok: false, status: 502, error: 'vault upstream unreachable' };
}
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
// Vaultwarden puts the human message in different places (error_description/error are often empty on a
// bad login; the real text is in `message` or `errorModel.message`). Check them all.
const errorModel = data.errorModel as Record<string, unknown> | undefined;
const candidates = [data.error_description, errorModel?.message, data.message, data.error];
const desc = candidates.find((c): c is string => typeof c === 'string' && c.length > 0) ?? 'vault login failed';
return { ok: false, status: res.status, error: desc };
}
return { ok: true, data };
}
export type PasswordGrantInput = {
email: string;
authHash: string;
clientId: string;
deviceIdentifier: string;
deviceType: string;
deviceName: string;
};
/** Owner login: exchange the client-derived auth hash for a Vaultwarden token set. */
export function passwordGrant(p: PasswordGrantInput): Promise<ConnectResult> {
const form = new URLSearchParams();
form.set('grant_type', 'password');
form.set('username', p.email);
form.set('password', p.authHash);
form.set('scope', 'api offline_access'); // offline_access → a refresh token comes back
form.set('client_id', p.clientId);
form.set('deviceIdentifier', p.deviceIdentifier);
form.set('deviceType', p.deviceType);
form.set('deviceName', p.deviceName);
return postConnectToken(form);
}
/** Renew the access token from the stored refresh token (no password needed). */
export function refreshGrant(refreshToken: string, clientId: string): Promise<ConnectResult> {
const form = new URLSearchParams();
form.set('grant_type', 'refresh_token');
form.set('refresh_token', refreshToken);
form.set('client_id', clientId);
return postConnectToken(form);
}
// Vaultwarden mixes PascalCase and camelCase field names across versions — read a field case-insensitively.
export function pick(data: Record<string, unknown>, name: string): unknown {
const lower = name.charAt(0).toLowerCase() + name.slice(1);
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return data[name] ?? data[lower] ?? data[upper];
}
/** Compute an absolute expiry from a token response's `expires_in` (seconds), if present. */
export function expiryFrom(data: Record<string, unknown>, now: number): Date | null {
const secs = data.expires_in;
return typeof secs === 'number' && Number.isFinite(secs) ? new Date(now + secs * 1000) : null;
}
-29
View File
@@ -1,29 +0,0 @@
// Generic proxy header hygiene for the platform-side /api/vault forwarder. The platform knows nothing
// about Vaultwarden — it only forwards faithfully to the officer-vault sidecar (loopback). Bodies are
// never touched; we only drop hop-by-hop headers and redact token-shaped query strings from logs.
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
'host',
]);
/** Copy headers, dropping only hop-by-hop / proxy-* ones. Authorization + device headers pass through. */
export function stripHopByHop(src: Headers): Headers {
const out = new Headers();
src.forEach((value, key) => {
const k = key.toLowerCase();
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
out.set(key, value);
});
return out;
}
const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi;
export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1<redacted>');
-110
View File
@@ -1,110 +0,0 @@
import { createRouter } from '../../create-router';
import { getVaultServerUrl } from './sidecar-server';
import { stripHopByHop, redactPath } from './proxy-util';
// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication.
//
// ── Why this exists ──
//
// The Bitwarden browser extension speaks Vaultwarden's protocol and nothing else: it obtains its own
// bearer token from `/identity/connect/token` and has nowhere to put a platform JWT. `/api/vault` cannot
// serve it — that router requires an Officer session and REPLACES the caller's Authorization header with
// a server-held Vaultwarden token, which is the right design for our own clients (the device then holds
// no vault credential at all) and an impossible one for a third-party client.
//
// So this mount exists to retire a second public hostname. Andre pointed the extension straight at
// Vaultwarden; that URL can now go away, and the extension can use the platform's.
//
// ── Why it is acceptable to leave unauthenticated ──
//
// It is not a new exposure, it is the same exposure through one door instead of two: everything here was
// already reachable at the Vaultwarden URL this replaces, guarded by the same thing that guards it now —
// the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden
// client today because it cannot: it has no credential to check.
//
// It is also going behind Tailscale, so the surface is a private network rather than the internet.
//
// ── TEMPORARY ──
//
// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to
// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore)
// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is
// never addressed directly by anything, and this mount is deleted rather than adjusted.
//
// ── The rule that keeps it safe ──
//
// DUMB PASS-THROUGH, and more so than `/api/vault`: that one rewrites the Authorization header, this one
// touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The
// vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body —
// paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable
// without an Officer session, which is the whole reason it must stay boring.
export const publicVaultRouter = createRouter();
const PREFIX = '/vaultwarden';
/**
* Paths that belong to Vaultwarden and to nothing else in this platform.
*
* These are served at the ROOT so the extension can be configured with the bare Officer URL — no
* `/vaultwarden` suffix — which is the point: one hostname, one URL, nothing to explain. None of them
* collide with an Officer route, so no sniffing is involved; the path alone is the answer.
*/
export const VAULT_ONLY_PREFIXES = ['/identity', '/notifications', '/icons', '/events'];
/**
* Is this a Bitwarden client talking to us?
*
* Needed only for `/api/*`, which is the one namespace both servers claim — Vaultwarden has
* `/api/sync`, `/api/ciphers`, `/api/settings/domains`; Officer has `/api/chat`, `/api/settings` and
* everything else. The path cannot decide it, so the client says who it is: every Bitwarden client
* stamps `Bitwarden-Client-Name` (`browser`, `desktop`, `cli`, `web`), and older ones `Device-Type`.
*
* This is ROUTING, not authentication, which is why trusting a client-supplied header is fine here: the
* worst a forged header achieves is reaching Vaultwarden, which then demands its own credential exactly
* as it would have. Nothing is authorised by this function.
*/
export const isBitwardenClient = (headers: Headers): boolean =>
headers.has('bitwarden-client-name') || headers.has('bitwarden-client-version') || headers.has('device-type');
/** Everything Vaultwarden serves, whichever door it arrived through. */
export const isVaultwardenPath = (path: string, headers: Headers): boolean => {
if (VAULT_ONLY_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))) return true;
return path.startsWith('/api/') && isBitwardenClient(headers);
};
publicVaultRouter.all('/*', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.text('Vault sidecar not available', 503);
const url = new URL(ctx.req.url);
// Mounted at `/vaultwarden` AND at the root prefixes above. Strip the prefix only when it is there,
// so `/vaultwarden/identity/...` and a bare `/identity/...` both reach Vaultwarden's own path.
const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/';
const target = `${base}${subpath}${url.search}`;
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
// Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden
// token is the only credential in play, and Vaultwarden is the only thing entitled to judge it.
const headers = stripHopByHop(ctx.req.raw.headers);
const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' };
if (hasBody) {
init.body = ctx.req.raw.body;
init.duplex = 'half';
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch {
console.error(`[vault:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`);
return ctx.text('Vault sidecar unreachable', 502);
}
// No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it
// injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the
// client knows how to handle — it re-authenticates with the master password.
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
});
-168
View File
@@ -1,168 +0,0 @@
import type { MiddlewareHandler } from 'hono';
import { createRouter } from '../../create-router';
import { originMiddleware, userMiddleware } from '../../_middlewares';
import { isSuperAdmin } from '../../super-admin';
import * as errors from '../../custom-errors';
import { getVaultServerUrl } from './sidecar-server';
import { stripHopByHop, redactPath } from './proxy-util';
import { passwordGrant, pick, expiryFrom, type PasswordGrantInput } from './broker';
import { getValidAccessToken, refreshAccess } from './token-store';
import { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey } from 'officerdb';
// Platform side of Officer Vault (VAULT_AUTH_SPEC.md). The device holds NO Vaultwarden token; a valid
// platform session authorizes vault access. This router:
// • gates every /api/vault/* on an owner platform session (userMiddleware, no bodyParser → streaming),
// • serves the native broker/unlock-key endpoints,
// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored
// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten.
export const vaultRouter = createRouter();
const PREFIX = '/api/vault';
// Owner-only. The global backstop already blocks non-owner tokens from /api/vault; this is explicit
// defense-in-depth and gives a clear error.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only');
return next();
};
vaultRouter.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check
vaultRouter.use(userMiddleware); // valid platform session (Authorization: Bearer <platform JWT> or ?token=)
vaultRouter.use(ownerGate);
// ── Native endpoints (registered before the catch-all) ──
// Reachability probe (owner-gated): forwards to the sidecar's /_health, which probes Vaultwarden's /alive.
vaultRouter.get('/_health', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.json({ ok: false, error: 'vault sidecar unavailable' }, 503);
try {
const r = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(5000) });
const body = await r.json().catch(() => ({ ok: r.ok }));
return Response.json(body, { status: r.status });
} catch {
return ctx.json({ ok: false, error: 'vault sidecar unreachable' }, 502);
}
});
// Token broker — exchange the client-derived auth hash for a Vaultwarden token set (stored server-side),
// and return the encrypted user-key material the client needs to unlock on-device.
vaultRouter.post('/session/login', async (ctx) => {
const user = ctx.get('user');
const b = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
const str = (v: unknown) => (typeof v === 'string' ? v : '');
const email = str(b.email);
const authHash = str(b.authHash);
if (!email || !authHash) return ctx.json({ ok: false, error: 'email and authHash required' }, 400);
const clientId = str(b.clientId) || 'mobile';
const input: PasswordGrantInput = {
email,
authHash,
clientId,
deviceIdentifier: str(b.deviceIdentifier),
deviceType: b.deviceType != null ? String(b.deviceType) : '',
deviceName: str(b.deviceName),
};
const result = await passwordGrant(input);
if (!result.ok) return Response.json({ ok: false, error: result.error }, { status: result.status });
const d = result.data;
const access = pick(d, 'access_token');
const refresh = pick(d, 'refresh_token');
if (typeof access !== 'string' || typeof refresh !== 'string') {
return ctx.json({ ok: false, error: 'vault returned no token' }, 502);
}
await setVaultTokens(user.id, {
accessToken: access,
refreshToken: refresh,
expiresAt: expiryFrom(d, Date.now()),
deviceIdentifier: input.deviceIdentifier || null,
clientId,
});
// Everything except the transport tokens (which the platform holds) is crypto material the SDK unlocks
// on-device — ciphertext to us. Vaultwarden returns BOTH classic (Key/PrivateKey/Kdf*) and v2
// (UserDecryptionOptions.MasterPasswordUnlock, AccountKeys); pass the whole native response through
// under `connectToken` so the SDK gets whatever it uses, alongside the spec's named aliases.
const connectToken: Record<string, unknown> = { ...d };
for (const k of ['access_token', 'refresh_token', 'token_type', 'expires_in']) delete connectToken[k];
return ctx.json({
ok: true,
protectedUserKey: pick(d, 'Key') ?? null,
privateKey: pick(d, 'PrivateKey') ?? null,
kdf: {
kdf: pick(d, 'Kdf') ?? null,
kdfIterations: pick(d, 'KdfIterations') ?? null,
kdfMemory: pick(d, 'KdfMemory') ?? null,
kdfParallelism: pick(d, 'KdfParallelism') ?? null,
},
connectToken,
});
});
// Officer-app unlock path: store / release the platform-held protector key (owner session only).
vaultRouter.put('/unlock-key', async (ctx) => {
const user = ctx.get('user');
const b = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
if (typeof b.wrappedKey !== 'string' || !b.wrappedKey) return ctx.json({ error: 'wrappedKey required' }, 400);
await setVaultUnlockKey(user.id, b.wrappedKey);
return ctx.json({ ok: true });
});
vaultRouter.get('/unlock-key', async (ctx) => {
const user = ctx.get('user');
const wrappedKey = await getVaultUnlockKey(user.id);
if (!wrappedKey) return ctx.json({ error: 'no unlock key stored' }, 404);
return ctx.json({ wrappedKey });
});
// ── Token-injecting proxy → sidecar → Vaultwarden ──
vaultRouter.all('/*', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.text('Vault sidecar not available', 503);
const user = ctx.get('user');
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice(PREFIX.length) || '/';
const target = `${base}${subpath}${url.search}`;
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
const started = Date.now();
// Inject a valid Vaultwarden token (proactively refreshed near expiry). No token yet → forward without
// one: unauthenticated endpoints (prelogin) work; authed ones get Vaultwarden's 401.
const accessToken = await getValidAccessToken(user.id);
const headers = stripHopByHop(ctx.req.raw.headers);
headers.delete('authorization'); // drop the platform JWT
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);
const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' };
if (hasBody) {
init.body = ctx.req.raw.body;
init.duplex = 'half';
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch {
console.error(`[vault] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`);
return ctx.text('Vault sidecar unreachable', 502);
}
// A 401 on a replayable (no-body) request → token likely just expired/revoked: refresh once and retry.
if (upstream.status === 401 && !hasBody) {
const ts = await getVaultTokens(user.id);
const fresh = ts ? await refreshAccess(user.id, ts) : null;
if (fresh) {
headers.set('authorization', `Bearer ${fresh}`);
upstream = await fetch(target, { method, headers, redirect: 'manual' }).catch(() => upstream);
}
}
console.log(`[vault] ${method} ${redactPath(subpath)} -> ${upstream.status} ${Date.now() - started}ms`);
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
});
-24
View File
@@ -1,24 +0,0 @@
import * as sidecar from '@@/sidecar-registry';
// The officer-vault sidecar starts its Vaultwarden reverse-proxy on a random loopback port and reports it
// here on connect. We remember it so `/api/vault/*` (HTTP) and the notifications WebSocket always forward
// to the current sidecar. The platform holds NO knowledge of Vaultwarden itself — only where the sidecar is.
let serverPort: number | null = null;
sidecar.on('vault:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
console.log(`[vault] sidecar proxy registered on port ${port}`);
});
/** Base URL of the sidecar's HTTP proxy, or null if the sidecar hasn't reported in yet. */
export function getVaultServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}
/** Same, as a WebSocket base for the notifications hub. */
export function getVaultServerWsUrl(): string | null {
return serverPort ? `ws://127.0.0.1:${serverPort}` : null;
}
-34
View File
@@ -1,34 +0,0 @@
import { getVaultTokens, updateVaultAccess, type VaultTokenSet } from 'officerdb';
import { refreshGrant, pick, expiryFrom } from './broker';
// Read-side of the brokered Vaultwarden token: hand out a currently-valid access token, refreshing it
// server-side (with the stored refresh token) when it's near expiry. Shared by the HTTP proxy and the
// notifications-WS proxy so both inject a fresh token. The device never sees any of this.
const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401
/** Renew the access token from the stored refresh token; persist and return it, or null on failure. */
export async function refreshAccess(userId: number, ts: VaultTokenSet): Promise<string | null> {
const r = await refreshGrant(ts.refreshToken, ts.clientId ?? 'mobile');
if (!r.ok) return null;
const access = pick(r.data, 'access_token');
const refresh = pick(r.data, 'refresh_token');
if (typeof access !== 'string') return null;
await updateVaultAccess(
userId,
access,
typeof refresh === 'string' ? refresh : ts.refreshToken,
expiryFrom(r.data, Date.now()),
);
return access;
}
/** A currently-valid Vaultwarden access token for the owner, or null if none is stored. */
export async function getValidAccessToken(userId: number): Promise<string | null> {
const ts = await getVaultTokens(userId);
if (!ts) return null;
if (ts.expiresAt && ts.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) {
return (await refreshAccess(userId, ts)) ?? ts.accessToken;
}
return ts.accessToken;
}
-162
View File
@@ -1,162 +0,0 @@
import type { ServerWebSocket } from 'bun';
import { resolveAuthToken } from '../../auth-token';
import { isTokenBlacklisted } from 'officerdb';
import { isSuperAdmin } from '../../super-admin';
import { getVaultServerWsUrl } from './sidecar-server';
import { getValidAccessToken } from './token-store';
// Platform side of the Bitwarden notifications WebSocket. The device connects with its platform JWT (via
// ?access_token=, how SignalR carries the token); we validate that session in `open`, then pipe the socket
// to the officer-vault sidecar with the stored Vaultwarden token injected — the device never holds it.
// Dumb pipe: text + binary frames both ways, no inspection. SignalR's HTTP long-poll fallback rides the
// HTTP proxy (vaultRouter) instead.
export type VaultWSData = {
provider: 'vault';
platformToken: string; // the platform JWT the device presented on the upgrade
vaultWsPath: string; // path + query after /api/vault, e.g. /notifications/hub?access_token=<jwt>
vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar
};
type UpstreamState = {
ws: WebSocket | null;
queue: (string | Uint8Array<ArrayBuffer>)[];
ready: boolean;
closed: boolean;
};
const upstreams = new Map<ServerWebSocket<VaultWSData>, UpstreamState>();
// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is.
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
function closeClient(ws: ServerWebSocket<VaultWSData>, state: UpstreamState, code: number, reason: string) {
state.closed = true;
upstreams.delete(ws);
try {
ws.close(code, reason);
} catch {
/* already closed */
}
}
// Swap the platform JWT in the path for the Vaultwarden access token before dialing upstream.
function injectToken(wsBase: string, vaultWsPath: string, vaultToken: string): string {
const qIdx = vaultWsPath.indexOf('?');
const path = qIdx >= 0 ? vaultWsPath.slice(0, qIdx) : vaultWsPath;
const params = new URLSearchParams(qIdx >= 0 ? vaultWsPath.slice(qIdx + 1) : '');
params.delete('token');
params.set('access_token', vaultToken);
return `${wsBase}${path}?${params.toString()}`;
}
export const vaultWebsocket = {
async open(ws: ServerWebSocket<VaultWSData>) {
// Register state synchronously so frames sent during the async setup below are buffered, not dropped.
const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false };
upstreams.set(ws, state);
// Deferred session validation (Bun requires the upgrade itself to be synchronous).
let userId: number;
try {
const payload = await resolveAuthToken(ws.data.platformToken);
if (!payload?.id) return closeClient(ws, state, 4001, 'Unauthorized');
if (payload.jti && (await isTokenBlacklisted(payload.jti))) return closeClient(ws, state, 4001, 'Unauthorized');
if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden');
userId = payload.id;
} catch {
return closeClient(ws, state, 4001, 'Unauthorized');
}
if (state.closed) return;
const vaultToken = await getValidAccessToken(userId);
if (!vaultToken) return closeClient(ws, state, 4001, 'No vault session');
const wsBase = getVaultServerWsUrl();
if (!wsBase) return closeClient(ws, state, 1011, 'Vault sidecar not available');
if (state.closed) return;
const protocols = ws.data.vaultWsProtocol
? ws.data.vaultWsProtocol
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const url = injectToken(wsBase, ws.data.vaultWsPath, vaultToken);
const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url);
upstream.binaryType = 'arraybuffer';
state.ws = upstream;
upstream.addEventListener('open', () => {
state.ready = true;
for (const m of state.queue) upstream.send(m);
state.queue.length = 0;
});
upstream.addEventListener('message', (ev) => {
try {
ws.send(ev.data as string | ArrayBuffer);
} catch {
/* client gone */
}
});
upstream.addEventListener('close', (ev) => {
upstreams.delete(ws);
try {
ws.close(ev.code || 1000, ev.reason || '');
} catch {
/* already closed */
}
});
upstream.addEventListener('error', () => {
upstreams.delete(ws);
try {
ws.close(1011, 'upstream error');
} catch {
/* already closed */
}
});
},
message(ws: ServerWebSocket<VaultWSData>, raw: string | Buffer) {
const state = upstreams.get(ws);
if (!state) return;
const payload = asPayload(raw);
if (state.ready && state.ws) state.ws.send(payload);
else state.queue.push(payload); // buffer until the upstream socket opens
},
close(ws: ServerWebSocket<VaultWSData>) {
const state = upstreams.get(ws);
if (state) {
state.closed = true;
try {
state.ws?.close();
} catch {
/* already closed */
}
upstreams.delete(ws);
}
},
drain() {},
};
const PREFIX = '/api/vault';
// Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query
// (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device
// never sends a Vaultwarden token — we inject the stored one upstream.
//
// There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation.
// It had defaulted to allow-everything, so it refused nothing on a real install.
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
const url = new URL(req.url);
const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || '';
if (!platformToken) return new Response('Unauthorized', { status: 401 });
const data: VaultWSData = {
provider: 'vault',
platformToken,
vaultWsPath: url.pathname.slice(PREFIX.length) + url.search,
vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined,
};
const ok = server.upgrade(req, { data });
if (!ok) return new Response('Upgrade failed', { status: 500 });
return undefined;
}
+4 -5
View File
@@ -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', () => {
+7 -21
View File
@@ -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: {
+10 -4
View File
@@ -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', () => {
-31
View File
@@ -22,8 +22,6 @@ import { dashboardsRouter } from './api/dashboards';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { devProxyRouter } from './api/dev-proxy/router';
import { pluginsRouter } from './api/plugins/router';
import { vaultRouter } from './api/vault/router';
import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
import { agentHandoffRouter } from './api/agent-handoff/router';
// import { slskdRouter } from './api/slskd/router';
// import { transmissionRouter } from './api/transmission/router';
@@ -42,9 +40,6 @@ import { claimIosProfile } from './api/dav/ios-profile';
// import { notifyRouter } from './api/notify/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
import { activityRouter } from './api/activity/router';
// Vault still hand-rolls its port capture, so it keeps a side-effect import; every other HTTP sidecar
// registers its listener when createSidecarProxy runs inside the router this file already imports.
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
import { queueRouter } from './api/queue/queue';
@@ -175,7 +170,6 @@ export const UNPROTECTED_API_PREFIXES: string[] = [
'/landing-page-data',
'/themes',
'/waitlist',
'/vault',
'/sidecar',
'/agent-handoff',
'/dev-proxy',
@@ -202,31 +196,6 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
app.route('/api/landing-page-data', landingPageDataRouter);
app.route('/api/themes', themesRouter);
app.route('/api/waitlist', waitlistRouter);
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
// notifications WebSocket is upgraded at the serve level (server.tsx).
app.route('/api/vault', vaultRouter);
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
// than a mode of the router above: that one requires an Officer session and swaps the caller's
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
// open is not a new exposure.
app.route('/vaultwarden', publicVaultRouter);
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
//
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
// header. An ordinary Officer request never matches, so nothing that worked before changes.
for (const prefix of VAULT_ONLY_PREFIXES) app.route(prefix, publicVaultRouter);
app.use('/api/*', async (ctx, next) => {
if (!isBitwardenClient(ctx.req.raw.headers)) return next();
return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
});
app.get('/api/integrations/google/callback', googleCallbackHandler);
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
+5 -3
View File
@@ -15,11 +15,13 @@ import { assertPermissionTotality, isExemptApiPath } from './totality';
// half, which is where the rules actually live. Everything here runs without a database.
// Mirrors the `handlers` map in server.tsx by hand, which is itself the drift this file keeps catching.
// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below.
const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'vault', 'sidecar'];
// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below. `vault` left
// on 2026-08-18 with `plugins/vault/`, taking its `/vault` api prefix and its ws provider with it: a
// plugin's routes are mounted behind the account gate like any other, so neither needs an exemption now.
const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'sidecar'];
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
const surface = () => ({
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'],
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
wsProviders: REAL_WS,
});
-4
View File
@@ -28,9 +28,6 @@ const EXEMPT_API_PREFIXES: Record<string, string> = {
// Public marketing surfaces, served to anonymous visitors.
'/landing-page-data': 'public landing page content, no account involved',
'/waitlist': 'public signup form, no account involved',
// The Bitwarden clients carry their own bearer token, not a platform JWT, so userMiddleware would 401
// them and a permission lookup has no account to resolve. Gated by origin scoping and Vaultwarden itself.
'/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer',
// Registration socket for sidecars. Process-to-process on loopback; there is no user on this path.
'/sidecar': 'sidecar registration, loopback process-to-process',
// The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so
@@ -53,7 +50,6 @@ const EXEMPT_API_PREFIXES: Record<string, string> = {
*/
const EXEMPT_WS_PROVIDERS: Record<string, string> = {
sidecar: 'sidecar registration, loopback process-to-process, no user',
vault: 'Vaultwarden notifications hub; upgraded by upgradeVaultWs with a Bitwarden token',
// The HMR socket of a dev server being previewed. Upgraded by `upgradeDevProxyWs`, which requires the
// same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner
// without carrying an account through `upgradeWs`. Refused outright without that cookie.
+20
View File
@@ -109,6 +109,26 @@ const BUNDLED: CatalogueEntry[] = [
// Email ships all four: the proxy router, `email_accounts`, the sync sidecar, and the mail panels.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'vault',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Vault',
summary: 'Your Vaultwarden, behind an Officer session — passwords for the apps that speak Bitwarden',
color: '#175ddc',
icon: 'KeyRound',
source: 'https://gitea.officer.dev/plugins/vault.git',
permissions: [
{
key: 'vault',
label: 'Vault',
description: 'The password vault, and the Officer-held session that opens it',
},
],
// No osDependencies: the sidecar is a Bun HTTP/WS proxy and reaches its upstream with `fetch`.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'music',
publisher: 'officerdev',
-236
View File
@@ -1,236 +0,0 @@
import type { ServerWebSocket } from 'bun';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { getVaultBase, getVaultWsBase, stripHopByHop, redactPath } from './upstream';
import { API_URL } from '../../officer-url.mjs';
// The officer-vault sidecar. Same philosophy as the other officer-* sidecars: a singleton process that
// registers with the API server and OWNS a contract — here, a transparent reverse-proxy to a self-hosted
// Vaultwarden (Bitwarden server). The platform API is just a thin, origin-gated forwarder to us; ALL
// knowledge of Vaultwarden (its URL, its paths, its notifications WebSocket) lives here, so the vault can
// one day be broken off into its own deployable unit without touching the platform.
//
// HARD RULE: dumb pass-through. The vault is end-to-end encrypted — we NEVER decrypt, parse, rewrite, or
// log request/response bodies. Method, path, query, headers, status and BOTH body streams pass through
// verbatim; nothing is buffered or cached.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — this server proxies to the Vaultwarden ROOT (the platform strips its /api/vault mount
// prefix before forwarding). So `/identity/*`, `/api/*`, `/notifications/*`, `/icons/*`, `/events/*` map
// straight through. `/notifications/hub` upgrades to a WebSocket (SignalR); its long-poll fallback rides
// the normal HTTP path. `GET /_health` is ours (probes Vaultwarden's /alive), not part of the contract.
// The server listens on a random loopback port, reported to the API on connect so it can route here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probe.port;
probe.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
// ── notifications WebSocket proxy (sidecar ↔ Vaultwarden) ──
type VaultWSData = { vaultWsPath: string; vaultWsProtocol?: string };
type UpstreamState = { ws: WebSocket; queue: (string | Uint8Array<ArrayBuffer>)[]; ready: boolean };
const upstreams = new Map<ServerWebSocket<VaultWSData>, UpstreamState>();
// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is.
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
const wsHandler = {
open(ws: ServerWebSocket<VaultWSData>) {
const wsBase = getVaultWsBase();
if (!wsBase) {
ws.close(1011, 'Vault upstream not configured');
return;
}
const protocols = ws.data.vaultWsProtocol
? ws.data.vaultWsProtocol
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const upstream = protocols?.length
? new WebSocket(`${wsBase}${ws.data.vaultWsPath}`, protocols)
: new WebSocket(`${wsBase}${ws.data.vaultWsPath}`);
upstream.binaryType = 'arraybuffer';
const state: UpstreamState = { ws: upstream, queue: [], ready: false };
upstreams.set(ws, state);
upstream.addEventListener('open', () => {
state.ready = true;
for (const m of state.queue) upstream.send(m);
state.queue.length = 0;
});
upstream.addEventListener('message', (ev) => {
try {
ws.send(ev.data as string | ArrayBuffer);
} catch {
/* client gone */
}
});
upstream.addEventListener('close', (ev) => {
upstreams.delete(ws);
try {
ws.close(ev.code || 1000, ev.reason || '');
} catch {
/* already closed */
}
});
upstream.addEventListener('error', () => {
upstreams.delete(ws);
try {
ws.close(1011, 'upstream error');
} catch {
/* already closed */
}
});
},
message(ws: ServerWebSocket<VaultWSData>, raw: string | Buffer) {
const state = upstreams.get(ws);
if (!state) return;
const payload = asPayload(raw);
if (state.ready) state.ws.send(payload);
else state.queue.push(payload); // buffer until the upstream socket opens
},
close(ws: ServerWebSocket<VaultWSData>) {
const state = upstreams.get(ws);
if (state) {
try {
state.ws.close();
} catch {
/* already closed */
}
upstreams.delete(ws);
}
},
drain() {},
};
// ── HTTP + WS server ──
const port = getFreePort();
const server = Bun.serve<VaultWSData>({
port,
hostname: '127.0.0.1',
idleTimeout: 255, // sync + attachments can idle; Bun caps this at 255s
maxRequestBodySize: 1024 * 1024 * 1024 * 2, // attachments
async fetch(req, server) {
const url = new URL(req.url);
// Reachability probe — ours, never touches vault data.
if (url.pathname === '/_health') {
const base = getVaultBase();
if (!base) return Response.json({ ok: false, error: 'VAULTWARDEN_URL not configured' }, { status: 503 });
const started = Date.now();
try {
const r = await fetch(`${base}/alive`, { method: 'GET', signal: AbortSignal.timeout(5000) });
return Response.json({ ok: r.ok, upstreamStatus: r.status, ms: Date.now() - started });
} catch {
return Response.json({ ok: false, error: 'upstream unreachable', ms: Date.now() - started }, { status: 502 });
}
}
// Notifications hub: upgrade the WebSocket here (piped to Vaultwarden by wsHandler); the SignalR
// long-poll fallback is not an upgrade, so it falls through to the HTTP proxy below.
if (url.pathname.startsWith('/notifications/') && req.headers.get('upgrade') === 'websocket') {
const ok = server.upgrade(req, {
data: {
vaultWsPath: url.pathname + url.search,
vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined,
} satisfies VaultWSData,
});
return ok ? undefined : new Response('Upgrade failed', { status: 500 });
}
// Faithful HTTP pass-through to Vaultwarden.
const base = getVaultBase();
if (!base) return new Response('Vault upstream not configured', { status: 503 });
const target = `${base}${url.pathname}${url.search}`;
const method = req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
const started = Date.now();
// Bun/undici require half-duplex to stream a request body straight through (attachments can be large).
const init: RequestInit & { duplex?: 'half' } = {
method,
headers: stripHopByHop(req.headers),
body: hasBody ? req.body : undefined,
redirect: 'manual', // a transparent proxy passes 3xx through rather than following them
};
if (hasBody) init.duplex = 'half';
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch {
console.error(`[vault] ${method} ${redactPath(url.pathname + url.search)} -> upstream unreachable`);
return new Response('Vault upstream unreachable', { status: 502 });
}
// Log at most method / path / status / duration — never bodies, tokens, or Authorization.
console.log(`[vault] ${method} ${redactPath(url.pathname)} -> ${upstream.status} ${Date.now() - started}ms`);
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
},
websocket: wsHandler,
});
console.log(`[vault] reverse-proxy listening on 127.0.0.1:${port} -> ${getVaultBase() ?? '(VAULTWARDEN_URL unset)'}`);
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
// ── Connect to API server ──
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'vault',
handles: ['vault'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
// Tell the API where our proxy is listening, so it can forward /api/vault/* here.
connection.send({ type: 'vault:server', port });
console.log(`[vault] reported proxy port ${port} to API`);
},
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`[vault] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-58
View File
@@ -1,58 +0,0 @@
// Vaultwarden upstream config + header hygiene for the officer-vault sidecar.
//
// HARD RULE (see BITWARDEN_SIDECAR_PROMPT.md): the vault is end-to-end encrypted. Nothing here — or in
// the sidecar that uses it — may decrypt, parse, rewrite, or log request/response BODIES. We only compute
// the upstream base URL and strip hop-by-hop headers. Bytes pass through untouched.
const { VAULTWARDEN_URL } = process.env;
let warnedUnset = false;
/** The Vaultwarden base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */
export function getVaultBase(): string | null {
const raw = VAULTWARDEN_URL?.trim();
if (!raw) {
if (!warnedUnset) {
console.warn('[vault] VAULTWARDEN_URL is unset — the sidecar will respond 503 until it is set');
warnedUnset = true;
}
return null;
}
return raw.replace(/\/+$/, '');
}
/** The same base as a WebSocket scheme (http→ws, https→wss) for the notifications hub. */
export function getVaultWsBase(): string | null {
const base = getVaultBase();
return base ? base.replace(/^http/i, 'ws') : null;
}
// Hop-by-hop headers must not cross a proxy hop (RFC 7230 §6.1). `upgrade` is handled by the dedicated
// WebSocket path; `host` is dropped so the outgoing fetch sets the upstream authority itself. Everything
// else — crucially Authorization, Content-Type, and Bitwarden device headers — is forwarded verbatim.
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
'host',
]);
/** Copy headers, dropping only hop-by-hop / proxy-* ones. Used for both request and response directions. */
export function stripHopByHop(src: Headers): Headers {
const out = new Headers();
src.forEach((value, key) => {
const k = key.toLowerCase();
if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return;
out.set(key, value);
});
return out;
}
// Redact anything token-shaped from a path+query before it reaches a log line.
const REDACT = /([?&](access_token|refresh_token|code|token)=)[^&]*/gi;
export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1<redacted>');