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:
+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(),
|
||||
});
|
||||
Reference in New Issue
Block a user