the plugin, written against karakeep instead of linkwarden

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.
This commit is contained in:
2026-09-05 21:36:19 +01:00
commit 58688365f0
13 changed files with 1283 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
# Karakeep
Your bookmarks, behind an Officer session. One self-hosted [Karakeep](https://karakeep.app) per Officer
install, pointed at by the owner, with every Officer user mapped onto an account of their own.
Officer stores no bookmarks. Karakeep owns those. What this plugin owns is the **credential** and the
**door**.
## The model
It is the Vault model:
| | |
| --- | --- |
| **One instance per install** | `karakeep_config`, one row pinned by a CHECK, set in the panel. Not `.env` — a plugin cannot ask the owner to edit a file and restart a process. |
| **A sidecar owns the upstream** | `officer-karakeep` reads the URL from Postgres with a short TTL and reverse-proxies. Nothing else knows where Karakeep is. |
| **Two doors** | An authenticated one for Officer's own UI, which injects the mapped user's API key so the device never holds one. And `/api/karakeep/kk`, unauthenticated, for the browser extension — which carries its own key. |
| **An account per user** | `karakeep_accounts`, provisioned per [`../USER_PROVISIONING.md`](../USER_PROVISIONING.md). |
## Why Karakeep, when this was first built against Linkwarden
Worth recording, because the plugin is otherwise the same shape.
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 telling the upstream
the address the *client* uses, which is not the address the *sidecar* dials. 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 — the same key works at any address and the upstream stays reachable on its own.
Three concrete differences, all from source:
| | Linkwarden | Karakeep |
| --- | --- | --- |
| Extension auth | username/password → session, plus NextAuth cookie routes | `apiKey` in settings, sent as a header |
| Host pinning | `NEXTAUTH_URL` must name the client-facing address | none |
| Custom headers | not supported | `customHeaders: z.record(string, string)` |
| Extension path | `${url}/api/v1/…` across the namespace | `${address}/api/trpc` |
| Provisioning | account creation yes; key minting needs a session | `users.create` + `apiKeys.exchange`, both public |
That last row is why nothing has to be pasted by hand: Officer creates the account, chooses the
password, and exchanges it for a key itself.
## A bare Officer origin
Not declared yet, and possible. The extension takes arbitrary `customHeaders`, so `protocolHeaders`
could route a bare `https://<officer>/` to this plugin the way Bitwarden's client headers route to Vault.
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.
## Two things carried over from the Linkwarden build
Both were bugs found by running it, and both are upstream-agnostic:
- **`content-encoding` must be stripped from a proxied response.** `fetch` decompresses the body but the
headers still claim gzip, so the client tries to gunzip plain JSON. It hides well — a 401 body is too
small to compress, so every unauthenticated probe passes and the first *real* request is the first to
fail.
- **`Set-Cookie` must be copied with `append`, not `set`.** It is the one header that legitimately
repeats. Karakeep should never need this; it is here because a proxy that is correct only for the
traffic its author happened to test is a trap for whoever changes the upstream later.
## State
Written against the source, **not yet run**. The response shapes below come from
`packages/api/routes/`, and are the first thing to verify against a live instance:
- `GET /api/v1/lists` → `{ lists: [...] }`
- `GET /api/v1/bookmarks` → `{ bookmarks: [...], nextCursor }`
- `GET /api/v1/bookmarks/search?q=` → the same, paginated
- `GET /api/v1/lists/:listId/bookmarks` → the same
No provisioning yet — `api/accounts.ts` is the next piece.