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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>');
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -112,11 +112,10 @@ describe('member provisioning is declared for every entry', () => {
|
||||
for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none');
|
||||
});
|
||||
|
||||
it('marks the vault as invite-only, because it cannot be otherwise', () => {
|
||||
// Vaultwarden derives its encryption key from the master password. A credential we could mint is a
|
||||
// vault we could read, so 'accounts' here would be a security defect rather than a feature.
|
||||
expect(byId('vault')!.members).toBe('invite');
|
||||
});
|
||||
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
||||
// `plugins/vault/`. Its reasoning — a service that derives its encryption key from the user's own
|
||||
// password cannot have credentials minted for it — now belongs to that plugin, and `members.test.ts`
|
||||
// keeps the 'invite' branch itself covered.
|
||||
});
|
||||
|
||||
describe('the UI manifest each sidecar carries', () => {
|
||||
|
||||
@@ -229,27 +229,13 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
{ key: 'secret', label: 'API token', type: 'secret', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vault',
|
||||
ui: { name: 'Vault', icon: 'KeyRound', color: '#175ddc', rootRoute: '/vault', routes: ['/vault'] },
|
||||
process: 'officer-vault',
|
||||
label: 'Vault',
|
||||
summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
|
||||
members: 'invite',
|
||||
modes: ['existing', 'provisioned'],
|
||||
// No permission entry exists for this one, and the reason is about CREDENTIALS, not routing.
|
||||
//
|
||||
// Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the
|
||||
// officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly.
|
||||
//
|
||||
// What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so
|
||||
// `userMiddleware` would 401 them and a permission lookup would have no account to resolve. Hence
|
||||
// `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead.
|
||||
// The install still governs whether the sidecar runs at all.
|
||||
permission: null,
|
||||
composeTemplate: 'vaultwarden',
|
||||
existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }],
|
||||
},
|
||||
// Vault was here until 2026-08-18, when it became `plugins/vault/`.
|
||||
//
|
||||
// Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having
|
||||
// bitten twice already. `permissionAvailability()` derives from `sidecar_installs`, and a plugin never
|
||||
// gets a row there (its install state is `plugin_installs`), so a leftover entry would make the
|
||||
// permission permanently `unavailable`, put its route into `deniedRoutes`, and withhold the dock tile —
|
||||
// on a server where the plugin is installed and working.
|
||||
{
|
||||
id: 'transmission',
|
||||
ui: {
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { plannedOutcome, servicesNeedingMemberProvision } from './members';
|
||||
import { byId } from './catalogue';
|
||||
import { byId, type CatalogueEntry } from './catalogue';
|
||||
|
||||
// `members: 'invite'` has no catalogue entry as of 2026-08-18 — the vault was the only one, and it left
|
||||
// to become `plugins/vault/`. The BRANCH is still live code any future entry can reach, so it is tested
|
||||
// against a synthetic entry rather than dropped: deleting the assertion with the last real user of a
|
||||
// case is how a switch arm stops being covered without anyone deciding that it should.
|
||||
const inviteOnly = { members: 'invite' } as CatalogueEntry;
|
||||
|
||||
describe('what adding a member would do', () => {
|
||||
it('predicts the outcome per service without contacting anything', () => {
|
||||
expect(plannedOutcome(byId('photos')!)).toBe('provisioned');
|
||||
expect(plannedOutcome(byId('vault')!)).toBe('invited');
|
||||
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
||||
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('which installed services have work to do', () => {
|
||||
it('skips single-tenant daemons entirely', () => {
|
||||
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'vault']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['photos', 'vault']);
|
||||
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'memos']).map((e) => e.id);
|
||||
expect(ids.sort()).toEqual(['memos', 'photos']);
|
||||
});
|
||||
|
||||
it('ignores services that are not installed', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
@@ -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>');
|
||||
Reference in New Issue
Block a user