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