Every response shape written from source was right: lists, bookmarks, search and the bookmark item's own fields. The door answers 200 with an API key and 401 without, from outside over the public hostname, first attempt - no NEXTAUTH_URL, no cookies, no container recreate, which is the whole argument for this upstream. The loopback trap caught this instance too: alpha:3001 answers 200 from anywhere except alpha, where the name resolves to 127.0.1.1 and nothing is bound. Officer runs there, so the config holds 127.0.0.1:3001.
92 lines
4.7 KiB
Markdown
92 lines
4.7 KiB
Markdown
# 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.
|
|
|
|
## Verified against a live instance
|
|
|
|
2026-09-05, against Karakeep on alpha. Every shape written from source turned out to be right:
|
|
|
|
| | |
|
|
| --- | --- |
|
|
| `GET /api/v1/lists` | `{ lists: [...] }` |
|
|
| `GET /api/v1/bookmarks` | `{ bookmarks: [...], nextCursor }` |
|
|
| `GET /api/v1/bookmarks/search?q=` | the same, paginated |
|
|
| a bookmark | `title`, `tags[]`, `content: { type: 'link', url, title, description }` |
|
|
|
|
And the door works from outside, over the public hostname, first attempt:
|
|
|
|
```
|
|
https://<officer>/api/karakeep/kk/api/v1/bookmarks
|
|
with an API key → 200, 20 bookmarks
|
|
without one → 401
|
|
```
|
|
|
|
No `NEXTAUTH_URL`, no cookies, no container recreate — which is the entire argument for this upstream
|
|
over the last one.
|
|
|
|
**The address the sidecar dials is loopback**, `http://127.0.0.1:3001`, and this bit again: `alpha:3001`
|
|
answers 200 from anywhere else and **000 from alpha itself**, because the name resolves to 127.0.1.1
|
|
there and Karakeep binds 127.0.0.1 and 100.64.0.1. Officer runs on that host.
|
|
|
|
No provisioning yet — `api/accounts.ts` is the next piece.
|