diff --git a/api/public-router.ts b/api/public-router.ts index ac65454..76521ab 100644 --- a/api/public-router.ts +++ b/api/public-router.ts @@ -55,6 +55,11 @@ publicVaultRouter.all('/*', async (ctx) => { if (!base) return ctx.text('Vault sidecar not available', 503); const url = new URL(ctx.req.url); + // Reached three ways, and the path is normalised for all of them: + // /api/vault/bw/identity/... the explicit address — strip the prefix + // /identity/... a claimed top-level path — already Vaultwarden's own path + // /api/sync diverted by a Bitwarden client header — also already correct + // Stripping only when the prefix is actually present is what makes one router serve all three. const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/'; const target = `${base}${subpath}${url.search}`; const method = ctx.req.method; diff --git a/api/router.ts b/api/router.ts index 73558e6..1baec7e 100644 --- a/api/router.ts +++ b/api/router.ts @@ -58,6 +58,20 @@ export const router = createRouter(); */ router.route('/bw', publicVaultRouter); +/** + * The same router, exported for the platform to mount ABOVE the account gate. + * + * Mounted by the platform at the top-level paths this plugin's manifest claims (`/identity`, + * `/notifications`, `/icons`, `/events`) and for `/api/*` requests carrying a Bitwarden client header. + * That combination is what lets a Bitwarden client be given the bare Officer URL and nothing else — + * which is the entire point, and what the `/bw` prefix above could not deliver: deriving five URLs by + * hand into an advanced settings pane is not a setup anyone completes. + * + * `/bw` stays as well. It costs nothing (the router strips whichever prefix it was reached through) and + * it is the explicit address for a client that would rather be told than infer. + */ +export const publicRouter = publicVaultRouter; + /** * Wipe this account's vault state when its session ends. Registered as a side effect of the router being * imported, which the platform does once when the plugin's routes are mounted — so the subscription's diff --git a/manifest.ts b/manifest.ts index 79e2b3a..6cc0354 100644 --- a/manifest.ts +++ b/manifest.ts @@ -49,4 +49,25 @@ export const manifest: PluginManifest = { re-checks containment at boot, so it can only ever mean `/api/vault/bw/*`. */ unauthenticatedPaths: ['/bw/*'], + + /* + The four paths a Bitwarden client derives from a bare server URL, claimed at the ROOT so that URL can + be the bare Officer one. This is what makes the setup "paste https://officer.example.com and log in" + rather than five fields copied into an advanced pane — which is the difference between a feature and + a thing nobody uses. + + None of them is a name Officer serves: they belong to Vaultwarden's protocol and to nothing else here, + so the path alone decides and no sniffing is involved. + */ + unauthenticatedRootPaths: ['/identity', '/notifications', '/icons', '/events'], + + /* + `/api/*` is the one namespace BOTH servers claim — Vaultwarden has /api/sync, /api/ciphers; Officer + has /api/chat, /api/settings. The path cannot separate them, so the client says who it is: every + Bitwarden client stamps one of these, and older ones `device-type`. + + Routing, not authentication. A forged header reaches Vaultwarden, which demands its own credential + exactly as it would have; nothing of Officer's is authorised by it. + */ + protocolHeaders: ['bitwarden-client-name', 'bitwarden-client-version', 'device-type'], };