pastilhas 2b3b986c0e settings belong at the bottom of the sidebar, not behind a gear in the header
The owner asked for this after failing to find it, which is the whole argument: a
gear in a header is where a settings control is easy to write and not where
anybody looks for one. The bottom of a sidebar is its conventional home, and a
labelled row rather than an icon means it says what it is.

The form opens ABOVE the button so the control does not move when you use it. A
disclosure that walks away from the pointer as it expands is the small kind of
wrong that makes a thing feel unfinished.

`aria-expanded` on the button, because it is a disclosure and a screen reader has
no other way to know the state of one.

The error path still force-opens it - "no account is mapped to you" is the state
every user starts in, and the fix is in that form.
2026-09-05 21:49:09 +01:00
2026-09-05 21:38:47 +01:00

Karakeep

Your bookmarks, behind an Officer session. One self-hosted Karakeep 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.

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.

S
Description
Your bookmarks, behind an Officer session — karakeep, with an account per user
Readme
76 KiB
Languages
TypeScript 100%