vault, extracted from the platform into its own repository
The fourth plugin out, after offscale, music and email. Two routers, a sidecar,
three tables and — new — a settings screen, since the vault never had a UI in
the platform.
What it is: a reverse-proxy to a Vaultwarden you already run, plus a credential
broker that keeps the vault session server-side so devices never hold one. It
stores no passwords and cannot read any; every byte through it is ciphertext.
Two things changed rather than moved:
• VAULTWARDEN_URL left .env for a one-row `vault_config` table with a panel
writing it. A plugin cannot ask the owner to edit a file and restart a
process, so the env var could not survive the move. Server-wide, decided
with the owner: one Vaultwarden per install. The sidecar reads it on a 10s
cache rather than per request, since it sits in front of every proxied byte.
• Core stopped importing clearVaultTokens/clearVaultUnlockKey from three auth
handlers. That is the security property behind the distress and panic
buttons, so it could not simply be dropped — the platform gained a small
account-lifecycle hook instead, and the severity mapping moved here where it
belongs. Core now names no table and no column of this plugin.
Two things were deleted rather than carried, both already dark since
2026-08-13 and both recorded in PLUGIN.md:
• The unauthenticated Bitwarden passthrough. A plugin's routes mount behind
Officer's account gate; top-level unauthenticated prefixes are not something
a manifest can declare. Its own comments already called it TEMPORARY,
pending a first-party browser extension that authenticates to Officer.
• The notifications websocket. No plugin owns a socket on this platform yet.
SignalR falls back to HTTP long-polling on its own, so clients still sync.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Vault — the extraction record
|
||||||
|
|
||||||
|
Extracted from the platform on **2026-08-18**, the fourth plugin out (after offscale, music, email).
|
||||||
|
`plugins/EXTRACTING-A-PLUGIN.md` is the runbook; this file is what was decided and what it cost.
|
||||||
|
|
||||||
|
## Where it came from
|
||||||
|
|
||||||
|
| Was | Is now |
|
||||||
|
| -------------------------------------- | ----------------------------- |
|
||||||
|
| `src/servers/api/vault/router.ts` | `api/router.ts` (+ config routes) |
|
||||||
|
| `src/servers/api/vault/broker.ts` | `api/broker.ts` |
|
||||||
|
| `src/servers/api/vault/token-store.ts` | `api/token-store.ts` |
|
||||||
|
| `src/servers/api/vault/proxy-util.ts` | `api/proxy-util.ts` |
|
||||||
|
| `src/servers/api/vault/sidecar-server.ts` | `api/sidecar-server.ts` |
|
||||||
|
| `src/servers/api/vault/public-router.ts` | **deleted** — see below |
|
||||||
|
| `src/servers/api/vault/websocket.ts` | **deleted** — see below |
|
||||||
|
| `src/servers/sidecar/vault/*` | `sidecar/*` |
|
||||||
|
| `src/databases/officer_db/src/vault/*` | `db/*` |
|
||||||
|
| — | `web/*` — new, there was no UI |
|
||||||
|
|
||||||
|
## The state it was found in
|
||||||
|
|
||||||
|
Vault had been **switched off since 2026-08-13** (`1eecc400`, "switch off Vaultwarden's routers, pending
|
||||||
|
extraction into a plugin") — mounts commented, code left on disk, tables commented out of `schema.ts` and
|
||||||
|
therefore dropped by a later push. It was briefly re-enabled earlier the same day as this extraction
|
||||||
|
(`c775e8c7`) on a misreading of what the owner wanted, then extracted properly. That commit is a dead end
|
||||||
|
kept in history rather than reverted, per the repo's own rule about committing the mistakes.
|
||||||
|
|
||||||
|
Consequence worth stating: **nothing regressed for anyone**, because nothing was live. `vault_tokens` and
|
||||||
|
`vault_unlock_keys` did not exist in Postgres and `sidecar_installs` had no vault row.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. The Vaultwarden URL moved from `.env` into the database — server-wide
|
||||||
|
|
||||||
|
`VAULTWARDEN_URL` is gone. It is now `vault_config`, a one-row table (`id` pinned to 1 by a CHECK), with a
|
||||||
|
settings panel at `/vault` writing it.
|
||||||
|
|
||||||
|
Server-wide rather than per-user, decided with the owner: one Vaultwarden per install. Jellyfin's registry
|
||||||
|
is per-user because that machine genuinely runs four instances; there is no such case here, and a `userId`
|
||||||
|
column would be a join paid on every request for a second row that does not exist.
|
||||||
|
|
||||||
|
The env var could not survive anyway — a plugin cannot ask the owner to edit a file and restart a process.
|
||||||
|
Installing it has to be enough.
|
||||||
|
|
||||||
|
**Cost:** the sidecar now reads its upstream from Postgres instead of memory. It caches with a 10s TTL
|
||||||
|
(`sidecar/upstream.ts`) rather than querying per request, because this sits in front of every proxied byte
|
||||||
|
including attachment uploads. The TTL is the whole mechanism — there is no push, because
|
||||||
|
`sidecar-registry.ts` has no generic "send a command to the sidecar named X" and building one for a cache
|
||||||
|
hint would be a channel built for the wrong reason.
|
||||||
|
|
||||||
|
### 2. Core stopped importing the vault, via a new lifecycle hook — NOT by reaching into its tables
|
||||||
|
|
||||||
|
The reverse dependency that decided the split. Three core auth sites — `signout.ts`, `revoke-handler.ts`,
|
||||||
|
`panic-handler.ts` — imported `clearVaultTokens` / `clearVaultUnlockKey` from `officerdb` and called them.
|
||||||
|
That is the security property behind the distress and panic buttons: "someone got in, make the vault need
|
||||||
|
a master password again."
|
||||||
|
|
||||||
|
Three options, and the stakes chose:
|
||||||
|
|
||||||
|
- **Drop the wipe.** A silent security regression on the path that exists for the worst day. No.
|
||||||
|
- **Have core `DELETE FROM vault_tokens` by name.** Removes the import, keeps the coupling, and moves it
|
||||||
|
somewhere worse — core would depend on this plugin's *schema*, so renaming a column would silently stop
|
||||||
|
the wipe. 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.
|
||||||
|
- **A lifecycle announcement.** Taken. `src/servers/account-lifecycle.ts` is ~30 lines: core emits
|
||||||
|
`signout` / `distress` / `panic` with a user id, 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.
|
||||||
|
|
||||||
|
The severity mapping moved into the plugin, where it belongs, and the behaviour is unchanged: `signout`
|
||||||
|
drops the brokered session and keeps the protector key; `distress` and `panic` drop both.
|
||||||
|
|
||||||
|
**Known hole, accepted:** uninstall keeps rows, so a panic while the plugin is uninstalled wipes nothing.
|
||||||
|
The tokens are unusable in that state (no routes mounted) and reinstalling is an owner action, so this is
|
||||||
|
narrow rather than dangerous — but it is real.
|
||||||
|
|
||||||
|
### 3. The unauthenticated Bitwarden passthrough was deleted, not carried
|
||||||
|
|
||||||
|
`public-router.ts` served Vaultwarden at Officer's own hostname with no Officer auth, so the Bitwarden
|
||||||
|
browser extension could be configured with the bare Officer URL — mounted at `/vaultwarden` and at four
|
||||||
|
root prefixes (`/identity`, `/notifications`, `/icons`, `/events`), with an `/api/*` diverter keying off
|
||||||
|
the `Bitwarden-Client-Name` header.
|
||||||
|
|
||||||
|
**A plugin cannot serve any of that.** Plugin routers mount into `protectedRouter` at
|
||||||
|
`/api/<name>` — behind the account gate, by design. Top-level unauthenticated prefixes are not something a
|
||||||
|
manifest can declare, and the Bun route table those paths need entries in is built once at `serve()`.
|
||||||
|
Keeping it would have meant core naming `/vaultwarden` and `/identity` in its own route table: the exact
|
||||||
|
vocabulary leak the runbook forbids, for a path its own comments already called TEMPORARY.
|
||||||
|
|
||||||
|
The owner's call was "nice to have, not a requirement", with the intended end state being a first-party
|
||||||
|
browser extension that authenticates to Officer like every other client — which deletes the need rather
|
||||||
|
than restoring the workaround. So it is deleted here rather than carried as dead code.
|
||||||
|
|
||||||
|
**This is the one user-visible loss**, and it was already dark since 2026-08-13.
|
||||||
|
|
||||||
|
### 4. The notifications websocket went with it
|
||||||
|
|
||||||
|
`/api/vault/notifications/*` was upgraded at the serve level and piped to the sidecar with the Vaultwarden
|
||||||
|
token injected. Same blocker as above plus a second one: **no plugin owns a websocket on this platform
|
||||||
|
yet** — `server.reload({ routes })` is proven and never called, and `handlers` in `server.tsx` is a static
|
||||||
|
map. Music parked `cliamp/` for exactly this.
|
||||||
|
|
||||||
|
SignalR degrades to HTTP long-polling on its own, and that path *does* extract — it rides the ordinary
|
||||||
|
HTTP proxy. So clients still sync; they learn about changes on their next poll rather than instantly.
|
||||||
|
|
||||||
|
The sidecar's own websocket proxy is kept and still works (it is what a future platform mechanism would
|
||||||
|
plug into). Its `open` handler had to become async because the upstream base now comes from the database —
|
||||||
|
state is registered before the first `await` so frames arriving during setup are queued rather than
|
||||||
|
dropped, which is the shape the deleted platform-side proxy already used.
|
||||||
|
|
||||||
|
### 5. Owner-only stayed owner-only, in the plugin's own gate
|
||||||
|
|
||||||
|
A plugin permission is grantable per role by design — the rule is uniform and has no `ownerOnly`. So a
|
||||||
|
member could hold `vault`. The router keeps its own `isSuperAdmin` gate rather than inheriting an answer:
|
||||||
|
the permission decides whether the *route* is reachable, and the vault is the owner's.
|
||||||
|
|
||||||
|
## What the platform lost
|
||||||
|
|
||||||
|
Deleted, not commented: both routers, the sidecar, the db directory, the `hono.ts` mounts and imports, the
|
||||||
|
`server.tsx` ws import / handler entry / route-table entries, `/vault` from `UNPROTECTED_API_PREFIXES`,
|
||||||
|
both entries in `totality.ts`'s exemption lists, both database barrels, and the `app-store/catalogue.ts`
|
||||||
|
entry.
|
||||||
|
|
||||||
|
**The catalogue entry deletion is required, not 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`, puts the route into `deniedRoutes`, and
|
||||||
|
withholds the dock tile on a server where the plugin is installed and healthy.
|
||||||
|
|
||||||
|
Two tests referenced the catalogue entry. Vault was the **only** `members: 'invite'` entry, so removing it
|
||||||
|
would have silently dropped coverage of that branch in `plannedOutcome` — it is now tested against a
|
||||||
|
synthetic entry instead, because 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.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- **The Bitwarden-direct path** (decision 3). Needs either a first-party extension or a platform mechanism
|
||||||
|
for manifest-declared public prefixes; the latter also needs `server.reload({ routes })` to actually be
|
||||||
|
called for the first time.
|
||||||
|
- **The notifications websocket** (decision 4). Blocked on plugin-owned websockets, platform-wide.
|
||||||
|
- **Additional accounts.** Everything is owner-only. Vaultwarden has its own users and mapping Officer
|
||||||
|
accounts onto them is unbuilt.
|
||||||
|
- **The panic-while-uninstalled hole** (decision 2).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Vault
|
||||||
|
|
||||||
|
Your passwords, in a Vaultwarden you already run, reached through Officer.
|
||||||
|
|
||||||
|
Vault does not store passwords and cannot read them. It is two things: a **reverse-proxy** to a
|
||||||
|
Vaultwarden server you point it at, and a **credential broker** that keeps the vault session on the server
|
||||||
|
so your devices never have to hold one. The vault is end-to-end encrypted — every byte through here is
|
||||||
|
ciphertext, and nothing in this plugin decrypts, parses, caches or logs a request body.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- **One address for everything.** Officer's own mobile app signs in to Officer and reaches the vault at
|
||||||
|
`/api/vault` — it never sees a Vaultwarden token, because the server holds it and injects it on the way
|
||||||
|
through.
|
||||||
|
- **A session that follows your Officer session.** Sign out and the brokered vault session goes with it.
|
||||||
|
Hit distress or panic and the stored unlock key goes too, so the next unlock needs the master password —
|
||||||
|
which is the whole point of those buttons.
|
||||||
|
- **A settings screen** at `/vault`: paste the URL of your Vaultwarden, see whether it answers, turn the
|
||||||
|
proxy off without forgetting where it lives.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
**A Vaultwarden you already run.** This plugin does not install one, provision a container, or manage
|
||||||
|
anything about it — you give it a URL and it forwards there. It is equally happy with one on the same
|
||||||
|
machine, one on your tailnet, or one behind a reverse proxy somewhere else.
|
||||||
|
|
||||||
|
Nothing else. No system packages, no host binaries.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install the plugin.
|
||||||
|
2. Open **/vault** and enter your Vaultwarden's address, e.g. `https://vault.example.com`.
|
||||||
|
3. Press **Check**. A green "Answering" means Officer can reach it.
|
||||||
|
|
||||||
|
Changing the URL later takes effect within about ten seconds — the sidecar re-reads it on a short timer, so
|
||||||
|
there is nothing to restart.
|
||||||
|
|
||||||
|
## What it deliberately does not do yet
|
||||||
|
|
||||||
|
Stated plainly, because a store listing that only says what works is an advertisement.
|
||||||
|
|
||||||
|
- **There is no password list, and there will not be one here.** Reading vault items needs the decryption
|
||||||
|
keys, which live on your devices. Use a Bitwarden client or Officer's mobile app.
|
||||||
|
- **Third-party Bitwarden clients cannot point at Officer.** The browser extension used to be able to,
|
||||||
|
through an unauthenticated passthrough at Officer's own hostname. That path could not come with the
|
||||||
|
plugin: a plugin's routes mount behind Officer's account gate, and top-level unauthenticated routes are
|
||||||
|
not something a plugin can declare. Point Bitwarden clients at your Vaultwarden directly for now. The
|
||||||
|
intended replacement is a first-party browser extension that authenticates to Officer like every other
|
||||||
|
client, which removes the need rather than restoring the workaround.
|
||||||
|
- **Push notifications fall back to polling.** Vaultwarden's live "your vault changed" socket is not
|
||||||
|
proxied — no plugin owns a websocket yet on this platform. SignalR degrades to HTTP long-polling on its
|
||||||
|
own, so clients still sync; they learn about changes on their next poll rather than instantly.
|
||||||
|
- **One Vaultwarden per install, and it is the owner's.** The URL is server-wide and every route is
|
||||||
|
owner-only. Mapping additional Officer accounts onto Vaultwarden's own users is a real feature and is
|
||||||
|
not built.
|
||||||
|
- **Uninstalling keeps your data.** The tables survive, as they do for every plugin — only deleting the
|
||||||
|
plugin's source drops them. Your Vaultwarden is untouched either way; this plugin never wrote to it.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// 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>');
|
||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
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 { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { onAccountLifecycle } from '@@/account-lifecycle';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
|
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 {
|
||||||
|
getVaultConfig,
|
||||||
|
setVaultConfig,
|
||||||
|
getVaultTokens,
|
||||||
|
setVaultTokens,
|
||||||
|
getVaultUnlockKey,
|
||||||
|
setVaultUnlockKey,
|
||||||
|
clearVaultTokens,
|
||||||
|
clearVaultUnlockKey,
|
||||||
|
} from '../db/queries';
|
||||||
|
|
||||||
|
// Platform side of Officer Vault. The device holds NO Vaultwarden token; a valid platform session
|
||||||
|
// authorizes vault access. This router:
|
||||||
|
// • gates everything on an owner platform session (userMiddleware, no bodyParser → streaming),
|
||||||
|
// • serves the instance config, the native broker and the 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.
|
||||||
|
//
|
||||||
|
// ── The prefix is DERIVED, not written ──
|
||||||
|
//
|
||||||
|
// It was the literal '/api/vault' before the extraction, which is right only by coincidence:
|
||||||
|
// `mountPrefix` returns `/vault` for a first-party publisher, and the catch-all below slices exactly
|
||||||
|
// `PREFIX.length` characters off the path to build the sidecar's. Published by anyone else this mounts at
|
||||||
|
// `/api/p/<publisher>/vault` and a literal would forward `/alice/vault/identity/...` to a sidecar
|
||||||
|
// expecting `/identity/...`. Same reasoning, and the same fix, as `plugins/music/api/router.ts`.
|
||||||
|
//
|
||||||
|
// `appName` is a literal for the reason recorded there too: the platform imports this module and reads
|
||||||
|
// `router`, so there is nowhere for it to inject the plugin's own directory name.
|
||||||
|
const PREFIX = `/api${mountPrefix({ appName: 'vault', manifest })}`;
|
||||||
|
|
||||||
|
export const router = createRouter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe this account's vault state when its session ends. Registered as a side effect of the router being
|
||||||
|
* imported, which the platform does once when the plugin's routes are mounted — so the subscription's
|
||||||
|
* lifetime is "installed and enabled", with no lifecycle of its own to get wrong.
|
||||||
|
*
|
||||||
|
* The three levels are the platform's, and the severity mapping is ours:
|
||||||
|
*
|
||||||
|
* signout drop the brokered session, KEEP the protector key — logging out should not cost a
|
||||||
|
* master-password re-setup, and the token is unusable without a platform session
|
||||||
|
* anyway (the one that was just blacklisted).
|
||||||
|
* distress / panic drop both. This is the "someone got in" path, so the convenience material goes
|
||||||
|
* too and the next unlock needs the master password. Exactly what the platform-side
|
||||||
|
* code did before the extraction; the mapping moved, the behaviour did not.
|
||||||
|
*
|
||||||
|
* Failures are swallowed per call, deliberately — `emitAccountLifecycle` already logs a rejection, and a
|
||||||
|
* vault whose tables are missing (uninstalled, never pushed) must not turn signing out into a 500.
|
||||||
|
*/
|
||||||
|
onAccountLifecycle(async (userId, event) => {
|
||||||
|
await clearVaultTokens(userId).catch(() => {});
|
||||||
|
if (event !== 'signout') await clearVaultUnlockKey(userId).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Owner-only, and this is the ONE gate that matters now.
|
||||||
|
//
|
||||||
|
// Before the extraction there were two: the platform's own backstop refused non-owner tokens on
|
||||||
|
// `/api/vault`, and this repeated it. A plugin's permission is grantable per role by design — the rule is
|
||||||
|
// uniform and has no `ownerOnly` — so a member could hold `vault` at read or write. That is fine for the
|
||||||
|
// permission (it decides whether the ROUTE is reachable) and not fine for the vault (one Vaultwarden
|
||||||
|
// instance, one brokered token set, all of it the owner's). So the router keeps its own answer rather
|
||||||
|
// than inheriting one, which is exactly what the plugin contract says a plugin's queries are for.
|
||||||
|
const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
||||||
|
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only');
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
router.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check
|
||||||
|
router.use(userMiddleware); // valid platform session (Authorization: Bearer <platform JWT> or ?token=)
|
||||||
|
router.use(ownerGate);
|
||||||
|
|
||||||
|
// ── Instance configuration (registered before the catch-all) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vault/config → `{ url, enabled, updatedAt, configured }`
|
||||||
|
*
|
||||||
|
* `configured` is derived rather than stored: a client wants to know "can this do anything yet", and
|
||||||
|
* that is a URL being present, not a row existing.
|
||||||
|
*/
|
||||||
|
router.get('/config', async (ctx) => {
|
||||||
|
const config = await getVaultConfig();
|
||||||
|
return ctx.json({ ...config, configured: !!config.url });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/vault/config `{ url?: string | null, enabled?: boolean }`
|
||||||
|
*
|
||||||
|
* Refuses anything that is not an absolute http(s) URL. Not a security boundary — this is owner-only and
|
||||||
|
* the value reaches `fetch`, never a shell — but a typo saved here fails later as "upstream unreachable",
|
||||||
|
* which sends the reader looking at their Vaultwarden instead of at what they typed.
|
||||||
|
*/
|
||||||
|
router.put('/config', async (ctx) => {
|
||||||
|
const body = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const patch: { url?: string | null; enabled?: boolean } = {};
|
||||||
|
|
||||||
|
if (body.url !== undefined) {
|
||||||
|
if (body.url === null || body.url === '') {
|
||||||
|
patch.url = null;
|
||||||
|
} else if (typeof body.url !== 'string') {
|
||||||
|
throw errors.BAD_REQUEST('url must be a string, or null to clear it');
|
||||||
|
} else {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(body.url.trim());
|
||||||
|
} catch {
|
||||||
|
throw errors.BAD_REQUEST('url must be an absolute URL, e.g. https://vault.example.com');
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw errors.BAD_REQUEST('url must be http:// or https://');
|
||||||
|
}
|
||||||
|
patch.url = body.url.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.enabled !== undefined) {
|
||||||
|
if (typeof body.enabled !== 'boolean') throw errors.BAD_REQUEST('enabled must be a boolean');
|
||||||
|
patch.enabled = body.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.url === undefined && patch.enabled === undefined) {
|
||||||
|
throw errors.BAD_REQUEST('nothing to update — send url and/or enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await setVaultConfig(patch);
|
||||||
|
return ctx.json({ ...config, configured: !!config.url });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vault/config/health — does the configured Vaultwarden actually answer?
|
||||||
|
*
|
||||||
|
* Forwards to the sidecar's own `/_health`, which probes Vaultwarden's `/alive`. Kept separate from
|
||||||
|
* `GET /config` so that reading the setting never blocks on a network round trip to a box that may be
|
||||||
|
* off — the panel renders the URL immediately and fills the status in behind it.
|
||||||
|
*/
|
||||||
|
async function probeHealth(): Promise<Response> {
|
||||||
|
const base = getVaultServerUrl();
|
||||||
|
if (!base) return Response.json({ ok: false, error: 'vault sidecar unavailable' }, { status: 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 Response.json({ ok: false, error: 'vault sidecar unreachable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/config/health', () => probeHealth());
|
||||||
|
|
||||||
|
// The same answer at its original path: the phone app already probes `/_health`, and renaming it would be
|
||||||
|
// a client change for no reason. `/config/health` is the name the settings panel reads better.
|
||||||
|
router.get('/_health', () => probeHealth());
|
||||||
|
|
||||||
|
// ── Native endpoints ──
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
router.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).
|
||||||
|
router.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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.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 ──
|
||||||
|
router.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) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { getVaultTokens, updateVaultAccess, type VaultTokenSet } from '../db/queries';
|
||||||
|
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;
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from 'officerdb/db';
|
||||||
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||||
|
import { vaultConfig, vaultTokens, vaultUnlockKeys } from './schema';
|
||||||
|
|
||||||
|
// 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. The key purpose is `vault`, minted on first
|
||||||
|
// use by the secret store (see docs/secret-store.md).
|
||||||
|
|
||||||
|
// ── Instance configuration (server-wide, one row) ──
|
||||||
|
|
||||||
|
export type VaultConfig = { url: string | null; enabled: boolean; updatedAt: Date | null };
|
||||||
|
|
||||||
|
const EMPTY_CONFIG: VaultConfig = { url: null, enabled: true, updatedAt: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this install's Vaultwarden is, or `url: null` when the owner has not pointed it anywhere yet.
|
||||||
|
*
|
||||||
|
* Never throws and never creates the row. A missing row and an unset URL are the same answer to the only
|
||||||
|
* question anyone asks here — "is there somewhere to forward to" — and an install that has not been
|
||||||
|
* configured is an ordinary state rather than an error.
|
||||||
|
*/
|
||||||
|
export async function getVaultConfig(): Promise<VaultConfig> {
|
||||||
|
const [row] = await db.select().from(vaultConfig).where(eq(vaultConfig.id, 1));
|
||||||
|
if (!row) return EMPTY_CONFIG;
|
||||||
|
return { url: row.url, enabled: row.enabled, updatedAt: row.updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trailing slash removed on write, so `${url}/identity/...` can never double the separator. */
|
||||||
|
const normaliseUrl = (raw: string): string => raw.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point this install at a Vaultwarden, or change which one. Upsert on the pinned id, so the first write
|
||||||
|
* creates the singleton and every later one replaces it.
|
||||||
|
*
|
||||||
|
* The URL is stored as given (normalised), NOT probed here. Reachability is a separate question with a
|
||||||
|
* separate answer — see the `/config/health` route — and a setter that refused an unreachable URL would
|
||||||
|
* make configuring a vault that is temporarily down impossible.
|
||||||
|
*/
|
||||||
|
export async function setVaultConfig(patch: { url?: string | null; enabled?: boolean }): Promise<VaultConfig> {
|
||||||
|
const values = {
|
||||||
|
id: 1,
|
||||||
|
...(patch.url !== undefined ? { url: patch.url === null ? null : normaliseUrl(patch.url) } : {}),
|
||||||
|
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const [row] = await db
|
||||||
|
.insert(vaultConfig)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({ target: vaultConfig.id, set: values })
|
||||||
|
.returning();
|
||||||
|
return row ? { url: row.url, enabled: row.enabled, updatedAt: row.updatedAt } : EMPTY_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Brokered Vaultwarden tokens ──
|
||||||
|
|
||||||
|
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 Officer-app unlock protector key ──
|
||||||
|
|
||||||
|
/** 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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { pgTable, integer, text, timestamp, boolean, check } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
|
// Officer Vault server-side state. 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 under the `vault` key purpose (officerdb/crypto).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this install's Vaultwarden lives. ONE ROW, ever — `id` is pinned to 1 by a CHECK.
|
||||||
|
*
|
||||||
|
* ── Why this is a table and not `VAULTWARDEN_URL` in .env ──
|
||||||
|
*
|
||||||
|
* It was the environment variable until the extraction (2026-08-18). Three things were wrong with that,
|
||||||
|
* and only the first is about tidiness:
|
||||||
|
*
|
||||||
|
* 1. `.env` is auto-loaded by bun into EVERY pm2 process, so a value only the vault sidecar needs was
|
||||||
|
* readable from `/proc/<pid>/environ` of every other one. Same argument `docs/secret-store.md` makes
|
||||||
|
* about keys, applied to a URL — lower stakes, identical shape.
|
||||||
|
* 2. A plugin cannot ask the owner to edit a file and restart a process. Installing it has to be enough,
|
||||||
|
* and pointing it at a server has to be something the UI can do.
|
||||||
|
* 3. An env var read at module load cannot change without a restart, so "point it somewhere else" meant
|
||||||
|
* editing a file, restarting the sidecar, and knowing to do both.
|
||||||
|
*
|
||||||
|
* ── Why server-wide rather than per-user ──
|
||||||
|
*
|
||||||
|
* Decided with the owner 2026-08-18: one Vaultwarden per install. Jellyfin's registry is per-user because
|
||||||
|
* this machine genuinely runs four instances; there is no such case here, and a `userId` column would be
|
||||||
|
* a join every request pays for a second row that does not exist. Onboarding additional accounts onto the
|
||||||
|
* same instance is a separate, later question — Vaultwarden has its own users, and mapping Officer
|
||||||
|
* accounts onto them is not a column here.
|
||||||
|
*/
|
||||||
|
export const vaultConfig = pgTable(
|
||||||
|
'vault_config',
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Always 1. A single-row table wants a way to say so structurally, or the second row arrives
|
||||||
|
* eventually and nothing says which one is real.
|
||||||
|
*/
|
||||||
|
id: integer('id').primaryKey().default(1),
|
||||||
|
/** Base URL, no trailing slash (normalised on write). Null before the owner has pointed it anywhere. */
|
||||||
|
url: text('url'),
|
||||||
|
/**
|
||||||
|
* Whether the reverse-proxy should serve at all. Distinct from `url` being set: the owner may want to
|
||||||
|
* stop exposing a vault without forgetting where it is.
|
||||||
|
*/
|
||||||
|
enabled: boolean('enabled').notNull().default(true),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
// The singleton rule, said structurally rather than by convention — `src/databases/CLAUDE.md`'s point
|
||||||
|
// about the schema owning what the database MAY contain, not only its shape.
|
||||||
|
(table) => [check('ck_vault_config_singleton', sql`${table.id} = 1`)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(),
|
||||||
|
});
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault — a reverse-proxy to a self-hosted Vaultwarden, with Officer holding the credential.
|
||||||
|
*
|
||||||
|
* ── What this plugin is, and what it deliberately is not ──
|
||||||
|
*
|
||||||
|
* It is NOT a password manager. It stores no vault items, renders no cipher list, and cannot decrypt
|
||||||
|
* anything: the vault is end-to-end encrypted and every route here is a dumb pass-through. What it owns is
|
||||||
|
* the CREDENTIAL problem — Officer brokers the Vaultwarden token, keeps it server-side, and injects it on
|
||||||
|
* the way through, so a device that has an Officer session never holds a vault token at all.
|
||||||
|
*
|
||||||
|
* It also does not install Vaultwarden. You point it at one you already run. That is the whole
|
||||||
|
* configuration surface, and it lives in the database rather than in `.env` — see `db/schema.ts`.
|
||||||
|
*
|
||||||
|
* ── No `osDependencies` ──
|
||||||
|
*
|
||||||
|
* Nothing here shells out. The sidecar is a Bun HTTP/WS proxy and the upstream is reached with `fetch`.
|
||||||
|
*/
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
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',
|
||||||
|
// A lucide glyph rather than artwork: this ships no assets/icon.png, and `KeyRound` is what the app
|
||||||
|
// store catalogue already used for it.
|
||||||
|
icon: 'KeyRound',
|
||||||
|
color: '#175ddc',
|
||||||
|
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'vault',
|
||||||
|
label: 'Vault',
|
||||||
|
description: 'The password vault, and the Officer-held session that opens it',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import type { ServerWebSocket } from 'bun';
|
||||||
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
import { getVaultBase, getVaultWsBase, stripHopByHop, redactPath } from './upstream';
|
||||||
|
|
||||||
|
// The officer-vault sidecar: a singleton process that registers with the API server and OWNS a contract —
|
||||||
|
// a transparent reverse-proxy to a self-hosted Vaultwarden (Bitwarden server). The platform API is a thin
|
||||||
|
// forwarder to us; ALL knowledge of Vaultwarden (its URL, its paths, its notifications WebSocket) lives
|
||||||
|
// here, which is what let the vault become a plugin without the platform learning anything new.
|
||||||
|
//
|
||||||
|
// 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 | 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>);
|
||||||
|
|
||||||
|
const wsHandler = {
|
||||||
|
// Async since the upstream base moved into the database — `open` used to be synchronous because it read
|
||||||
|
// an environment variable. The state entry is registered BEFORE the first await so frames arriving
|
||||||
|
// during setup are queued rather than dropped; that is the same shape the platform-side proxy already
|
||||||
|
// used, for the same reason, and it is why `ws` is nullable here now.
|
||||||
|
async open(ws: ServerWebSocket<VaultWSData>) {
|
||||||
|
const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false };
|
||||||
|
upstreams.set(ws, state);
|
||||||
|
|
||||||
|
const wsBase = await getVaultWsBase();
|
||||||
|
if (!wsBase) {
|
||||||
|
state.closed = true;
|
||||||
|
upstreams.delete(ws);
|
||||||
|
ws.close(1011, 'Vault upstream not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.closed) return; // client hung up while we were reading the config
|
||||||
|
|
||||||
|
const protocols = ws.data.vaultWsProtocol
|
||||||
|
? ws.data.vaultWsProtocol
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const target = `${wsBase}${ws.data.vaultWsPath}`;
|
||||||
|
const upstream = protocols?.length ? new WebSocket(target, protocols) : new WebSocket(target);
|
||||||
|
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() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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 = await getVaultBase();
|
||||||
|
if (!base) return Response.json({ ok: false, error: 'no Vaultwarden URL 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, url: base });
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{ ok: false, error: 'upstream unreachable', ms: Date.now() - started, url: base },
|
||||||
|
{ 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 = await 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget: the config read must not hold up the listener, and an unconfigured install is an
|
||||||
|
// ordinary state that the panel exists to fix rather than something to announce as a failure.
|
||||||
|
void getVaultBase().then((base) => {
|
||||||
|
console.log(`[vault] reverse-proxy listening on 127.0.0.1:${port} -> ${base ?? '(no URL configured yet)'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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'));
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { getVaultConfig } from '../db/queries';
|
||||||
|
|
||||||
|
// Vaultwarden upstream config + header hygiene for the officer-vault sidecar.
|
||||||
|
//
|
||||||
|
// HARD RULE: 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.
|
||||||
|
//
|
||||||
|
// ── The URL comes from the database now, not VAULTWARDEN_URL ──
|
||||||
|
//
|
||||||
|
// It was `process.env.VAULTWARDEN_URL`, read once at module load, until the plugin extraction on
|
||||||
|
// 2026-08-18. See `db/schema.ts` → `vaultConfig` for why it moved. The consequence here is the only
|
||||||
|
// interesting part: a value that used to be constant for the process lifetime is now something the owner
|
||||||
|
// can change from a settings panel while the sidecar is running.
|
||||||
|
//
|
||||||
|
// ── Why a cache with a TTL, rather than reading per request ──
|
||||||
|
//
|
||||||
|
// This sits in front of every proxied byte, including attachment uploads. A Postgres round trip per
|
||||||
|
// request would put the database on the hot path of a reverse-proxy whose entire design rule is that it
|
||||||
|
// does as little as possible. So the answer is cached.
|
||||||
|
//
|
||||||
|
// The TTL is what makes "change it in the UI" work without a restart, and 10 seconds is chosen against
|
||||||
|
// what the value actually is: a URL the owner edits by hand, roughly never, and then wants to see take
|
||||||
|
// effect. Ten seconds is imperceptible to someone who just pressed Save and free the rest of the time.
|
||||||
|
//
|
||||||
|
// The TTL is the WHOLE mechanism, deliberately — there is no push. `sidecar-registry.ts` carries events
|
||||||
|
// from a sidecar to the platform and typed helpers for the claude/opencode/vnc ones, but no generic
|
||||||
|
// "send this command to the sidecar named X", so the config route in the other process has nothing to
|
||||||
|
// call. Adding one for a value that changes twice a year would be a channel built for a cache hint.
|
||||||
|
//
|
||||||
|
// A failed read keeps serving the last known-good value rather than dropping to null. A database blip
|
||||||
|
// must not turn into "vault not configured", which is indistinguishable on screen from the owner having
|
||||||
|
// never set it up.
|
||||||
|
|
||||||
|
const TTL_MS = 10_000;
|
||||||
|
|
||||||
|
type Cached = { url: string | null; enabled: boolean; at: number };
|
||||||
|
let cache: Cached | null = null;
|
||||||
|
let inflight: Promise<Cached> | null = null;
|
||||||
|
|
||||||
|
async function read(): Promise<Cached> {
|
||||||
|
try {
|
||||||
|
const config = await getVaultConfig();
|
||||||
|
cache = { url: config.url, enabled: config.enabled, at: Date.now() };
|
||||||
|
return cache;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[vault] could not read config: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
// Keep serving what we had. Only a cold cache (no successful read ever) reports "unconfigured".
|
||||||
|
if (cache) return { ...cache, at: Date.now() };
|
||||||
|
return { url: null, enabled: true, at: Date.now() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function current(): Promise<Cached> {
|
||||||
|
if (cache && Date.now() - cache.at < TTL_MS) return cache;
|
||||||
|
// One read at a time. Under load every request would otherwise miss the same expired entry and fire its
|
||||||
|
// own query — the stampede that makes a cache slower than no cache.
|
||||||
|
inflight ??= read().finally(() => {
|
||||||
|
inflight = null;
|
||||||
|
});
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Vaultwarden base URL (no trailing slash), or null when unconfigured/disabled (the sidecar 503s). */
|
||||||
|
export async function getVaultBase(): Promise<string | null> {
|
||||||
|
const { url, enabled } = await current();
|
||||||
|
if (!enabled) return null;
|
||||||
|
return url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same base as a WebSocket scheme (http→ws, https→wss) for the notifications hub. */
|
||||||
|
export async function getVaultWsBase(): Promise<string | null> {
|
||||||
|
const base = await 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>');
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { KeyRound, Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { toneFill, toneText, type Tone } from '@/components/Data';
|
||||||
|
|
||||||
|
// The whole /vault screen: point this install at a Vaultwarden, and see whether it answers.
|
||||||
|
//
|
||||||
|
// There is no cipher list here and there will not be one. The vault is end-to-end encrypted and this
|
||||||
|
// plugin is a credential broker plus a reverse-proxy — the things that read vault items are the Bitwarden
|
||||||
|
// apps and Officer's own mobile client, both of which hold the keys this server never sees.
|
||||||
|
//
|
||||||
|
// Every colour is a semantic token (see components/Data/tone.ts). A plugin that reaches for a palette
|
||||||
|
// class stops following the theme, which is the one rule `plugins/EXTRACTING-A-PLUGIN.md` states twice.
|
||||||
|
|
||||||
|
type VaultConfig = {
|
||||||
|
url: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
updatedAt: string | null;
|
||||||
|
configured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HealthResult = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
upstreamStatus?: number;
|
||||||
|
ms?: number;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Field = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Pill = ({ tone, children }: { tone: Tone; children: React.ReactNode }) => (
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${toneFill[tone]}`}>{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VaultPanel = () => {
|
||||||
|
const { get, put } = useClient();
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<VaultConfig | null>(null);
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [health, setHealth] = useState<HealthResult | null>(null);
|
||||||
|
const [probing, setProbing] = useState(false);
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const next = await get<VaultConfig>('/vault/config');
|
||||||
|
setConfig(next);
|
||||||
|
setUrl(next.url ?? '');
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'could not read the vault configuration');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [get]);
|
||||||
|
|
||||||
|
// Never throws: an unreachable vault is the thing this panel exists to SHOW, not an error state for the
|
||||||
|
// panel itself. A rejected probe becomes a result with `ok: false`, same as a 502 body would.
|
||||||
|
const probe = useCallback(async () => {
|
||||||
|
setProbing(true);
|
||||||
|
try {
|
||||||
|
setHealth(await get<HealthResult>('/vault/config/health'));
|
||||||
|
} catch (err) {
|
||||||
|
setHealth({ ok: false, error: err instanceof Error ? err.message : 'probe failed' });
|
||||||
|
} finally {
|
||||||
|
setProbing(false);
|
||||||
|
}
|
||||||
|
}, [get]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConfig();
|
||||||
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
// Only probe once there is somewhere to probe. Asking about an unconfigured vault returns a 503 that
|
||||||
|
// reads on screen as a fault, when the honest answer is "you have not set this up yet".
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.configured) void probe();
|
||||||
|
}, [config?.configured, probe]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const next = await put<VaultConfig>('/vault/config', { url: url.trim() || null });
|
||||||
|
setConfig(next);
|
||||||
|
setUrl(next.url ?? '');
|
||||||
|
// The sidecar re-reads its cached URL on a 10s TTL (see sidecar/upstream.ts), so a probe fired
|
||||||
|
// immediately would still be answered against the OLD upstream and look like the save failed.
|
||||||
|
setHealth(null);
|
||||||
|
setTimeout(() => void probe(), 10_500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'could not save');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnabled = async () => {
|
||||||
|
if (!config) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setConfig(await put<VaultConfig>('/vault/config', { enabled: !config.enabled }));
|
||||||
|
setHealth(null);
|
||||||
|
setTimeout(() => void probe(), 10_500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'could not save');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirty = (config?.url ?? '') !== url.trim();
|
||||||
|
|
||||||
|
const statusTone: Tone = !config?.configured ? 'neutral' : !config.enabled ? 'warning' : health?.ok ? 'success' : health ? 'danger' : 'busy';
|
||||||
|
const statusLabel = !config?.configured
|
||||||
|
? 'Not configured'
|
||||||
|
: !config.enabled
|
||||||
|
? 'Disabled'
|
||||||
|
: health?.ok
|
||||||
|
? 'Reachable'
|
||||||
|
: health
|
||||||
|
? 'Unreachable'
|
||||||
|
: 'Checking…';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-col gap-6 p-6">
|
||||||
|
<header className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-lg font-semibold">Vault</h1>
|
||||||
|
<Pill tone={statusTone}>{statusLabel}</Pill>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Point Officer at a Vaultwarden you already run. Officer holds the vault session so your
|
||||||
|
devices never have to — they sign in to Officer instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 rounded-xl border border-border p-4">
|
||||||
|
<Field label="Vaultwarden URL">
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={(ev) => setUrl(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter' && dirty && !saving) void save();
|
||||||
|
}}
|
||||||
|
placeholder="https://vault.example.com"
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The address of your own Vaultwarden server. Officer never installs or manages it — it forwards
|
||||||
|
to it, and stores nothing from inside it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className={`text-sm ${toneText.danger}`}>{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{dirty && !saving && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUrl(config?.url ?? '')}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{config?.configured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleEnabled()}
|
||||||
|
disabled={saving}
|
||||||
|
className="ml-auto rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{config.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{config?.configured && (
|
||||||
|
<section className="flex flex-col gap-3 rounded-xl border border-border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Connection</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void probe()}
|
||||||
|
disabled={probing}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${probing ? 'animate-spin' : ''}`} />
|
||||||
|
Check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{health ? (
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Pill tone={health.ok ? 'success' : 'danger'}>{health.ok ? 'Answering' : 'No answer'}</Pill>
|
||||||
|
{health.ms !== undefined && <span>{health.ms}ms</span>}
|
||||||
|
{health.upstreamStatus !== undefined && <span>HTTP {health.upstreamStatus}</span>}
|
||||||
|
</div>
|
||||||
|
{health.error && <span className={toneText.danger}>{health.error}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{probing ? 'Checking…' : 'Not checked since the last change.'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
// A single full-width panel. The framework requires a layout even when there is only one thing in it —
|
||||||
|
// the plugin contributes panels and an arrangement, never a screen — and this is that arrangement
|
||||||
|
// honestly stated rather than padded out with a sidebar nothing needs.
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'vault-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [{ node: { type: 'panel', id: 'vault-settings', appType: 'vault-settings' }, size: 100 }],
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { KeyRound } from 'lucide-react';
|
||||||
|
import { VaultPanel } from './VaultPanel';
|
||||||
|
|
||||||
|
// One panel, and probably always one: the vault's UI is "where is it, and does it answer". Everything a
|
||||||
|
// person actually does WITH the vault happens in a Bitwarden client or Officer's mobile app, both of
|
||||||
|
// which hold decryption keys this server does not.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps it out of the generic panel picker — it belongs to the /vault screen
|
||||||
|
// rather than being something to drop into an arbitrary dashboard.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'vault-settings', name: 'Vault', icon: KeyRound, component: VaultPanel, availableOnPanel: false },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user