Same model as Vault: one instance per install, a sidecar that is the only thing
knowing where it is, two doors - one authenticated for our own UI that injects
the caller's credential, one unauthenticated for a client that carries its own -
and an account per Officer user.
── Why this upstream ──
Linkwarden authenticates its extension through NextAuth: host-scoped cookies,
CSRF checked against the host, absolute URLs baked from NEXTAUTH_URL. Proxying
that means telling the upstream the address the CLIENT uses, which is not the
address the SIDECAR dials, and getting it wrong bounces the extension out of the
proxy mid-login. It cost a NEXTAUTH_URL change, a container recreate, a lost csrf
cookie, and it retired the upstream's own web login as the price of going through
Officer.
Karakeep's extension holds an API key and sends it as a header. Nothing is
host-pinned, so none of that exists. And provisioning needs nothing pasted:
users.create and apiKeys.exchange are both public procedures, so Officer creates
the account, chooses the password, and mints the key itself - where Linkwarden
could only take a key a person made by hand.
── Carried over deliberately ──
Two fixes from the Linkwarden build, both upstream-agnostic and both found by
running it rather than reading it. `content-encoding` stripped from proxied
responses, because fetch decompresses the body while the headers still claim
gzip - which hides until the first response big enough to compress. And
Set-Cookie copied with append rather than set, because it is the one header that
legitimately repeats. Karakeep should never need the second one; it is here
because a proxy correct only for the traffic its author tested is a trap for
whoever changes the upstream later.
Also carried: the unauthenticated door is hand-written rather than the shared
createSidecarProxy router, which does ctx.get("user").id and 500s where there is
no session.
Written against source, not yet run. The response shapes in the panels come from
packages/api/routes/ and are the first thing to check against a live instance.
No provisioning yet.
75 lines
3.5 KiB
TypeScript
75 lines
3.5 KiB
TypeScript
import type { PluginManifest } from '@@/plugins/manifest';
|
|
|
|
/**
|
|
* Karakeep — your bookmarks, behind an Officer session.
|
|
*
|
|
* ── The model, which is Vault's ──
|
|
*
|
|
* One Karakeep per Officer install, pointed at by the owner. Officer stores no bookmarks: Karakeep owns
|
|
* those. What this plugin owns is the CREDENTIAL and the door. Where Vault brokers one account, this maps
|
|
* every Officer user onto a Karakeep account of their own — `db/schema.ts`, and
|
|
* `plugins/USER_PROVISIONING.md` for the contract that governs creating one.
|
|
*
|
|
* ── Why Karakeep and not Linkwarden, which this was first built against ──
|
|
*
|
|
* Worth recording, because the plugin is otherwise the same shape and somebody will wonder.
|
|
*
|
|
* Linkwarden authenticates its extension through NextAuth: host-scoped session cookies, CSRF checked
|
|
* against the host, and absolute URLs baked from `NEXTAUTH_URL`. Proxying that means the upstream has to
|
|
* be told the address the CLIENT uses, which is not the address the sidecar dials, and getting it wrong
|
|
* bounces the extension out of the proxy mid-login. It cost a NEXTAUTH_URL change, a lost csrf cookie and
|
|
* a container recreate, and it retired the upstream's own web login as the price of going through Officer.
|
|
*
|
|
* Karakeep's extension holds an API KEY and sends it as a header. Nothing is host-pinned, so none of the
|
|
* above exists: the same key works at any address, and the upstream stays reachable on its own.
|
|
*
|
|
* ── The extension, and why one prefix is enough ──
|
|
*
|
|
* It calls `${address}/api/trpc`, so pointing it at `https://<officer>/api/karakeep/kk` puts everything
|
|
* it needs under one declared path. No root-path claims, and no header routing.
|
|
*
|
|
* A bare Officer origin — the Bitwarden arrangement, where you paste the Officer URL and nothing else —
|
|
* is possible here and is NOT declared yet. It would need `protocolHeaders`, and Karakeep's extension can
|
|
* carry one because its settings take arbitrary `customHeaders`. That is a real option rather than a
|
|
* wish, and it is left until the path form is proven, because it asks the owner to configure a header
|
|
* whose purpose is invisible from where they are typing it.
|
|
*
|
|
* ── No `osDependencies` ──
|
|
*
|
|
* Nothing shells out. The sidecar is a Bun HTTP proxy and the upstream is reached with `fetch`.
|
|
*/
|
|
export const manifest: PluginManifest = {
|
|
publisher: 'officerdev',
|
|
version: '2026.09.05.01',
|
|
platform: '>=1.0.0',
|
|
|
|
label: 'Karakeep',
|
|
summary: 'Your bookmarks, behind an Officer session — saved, archived and searchable',
|
|
tags: ['Personal', 'Degoogling'],
|
|
icon: 'Bookmark',
|
|
color: '#14b8a6',
|
|
|
|
permissions: [
|
|
{
|
|
key: 'karakeep',
|
|
label: 'Karakeep',
|
|
description: 'Your saved bookmarks, lists and their archived pages',
|
|
/*
|
|
Reading and saving is every user's. Pointing the install at a server and creating accounts on it
|
|
is not — `/config` holds the address the whole install uses and `/accounts` provisions against an
|
|
admin credential. Both are prefix-matched, so the trees beneath them are covered.
|
|
*/
|
|
ownerOnly: ['/config', '/accounts'],
|
|
},
|
|
],
|
|
|
|
/*
|
|
The extension's door, served without an Officer session.
|
|
|
|
A stock Karakeep extension carries its own API key and has nowhere to put an Officer JWT. Same
|
|
argument as Vault's `/bw`: it is the same exposure through one address instead of two, and it is what
|
|
lets Karakeep stop being reachable on its own.
|
|
*/
|
|
unauthenticatedPaths: ['/kk/*'],
|
|
};
|