One Linkwarden per Officer install, pointed at by the owner, with every Officer user mapped onto an account of their own. Officer stores no bookmarks; it owns the credential and the door. The model is Vault's, and the parts that are the same are the same on purpose: a one-row config table rather than an env var, because a plugin cannot ask the owner to edit a file and restart a process; a sidecar that is the only thing knowing where the upstream is; and two doors, one authenticated for our own UI and one unauthenticated for a client that carries its own credential. `linkwarden_accounts` is the part Vault does not have. Its schema says so in as many words - "mapping Officer accounts onto them is not a column here" - so this is new work rather than a copy, and it follows plugins/USER_PROVISIONING.md, written from the photos plugin against Immich. ── The difference that matters ── Vaultwarden speaks a host-agnostic bearer protocol. Linkwarden is a Next.js app using NextAuth, which issues host-scoped cookies, checks CSRF against the host, and hands clients absolute URLs built from NEXTAUTH_URL. Measured rather than inferred: a sign-in posted to the tailnet address answers with an absolute URL to a hostname that does not answer at all. So serving the extension through Officer needs NEXTAUTH_URL to name the address Officer serves it at. That is a change to the container rather than to this plugin, and it is a prerequisite, not a preference. The API half is unaffected - /api/v1/* takes a bearer token and never touches NextAuth - so the panel and anything an agent does work either way. Verified against Linkwarden v2.16.2 on 2026-09-05: every route 401 without a token and 200 with one, including /api/v1/users, which is the admin surface provisioning needs. Links carry textContent and readable, which is what makes a saved page searchable rather than merely re-openable. Foundation only. No sidecar, routers, provisioning or panel yet.
65 lines
3.3 KiB
Markdown
65 lines
3.3 KiB
Markdown
# Linkwarden
|
|
|
|
Your bookmarks, behind an Officer session. One self-hosted [Linkwarden](https://linkwarden.app) per
|
|
Officer install, pointed at by the owner, with every Officer user mapped onto an account of their own.
|
|
|
|
Officer stores no bookmarks. Linkwarden owns those. What this plugin owns is the **credential** and the
|
|
**door**.
|
|
|
|
## The model
|
|
|
|
It is the Vault model, and deliberately so:
|
|
|
|
| | |
|
|
| --- | --- |
|
|
| **One instance per install** | `linkwarden_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-linkwarden` reads the URL from Postgres with a short TTL and reverse-proxies. Nothing else knows where Linkwarden is. |
|
|
| **Two doors** | An authenticated one for Officer's own UI, which injects the mapped user's token so the device never holds one. And `/api/linkwarden/lw`, unauthenticated, for the browser extension — which carries its own credential and has nowhere to put an Officer JWT. |
|
|
| **A mapped account per user** | `linkwarden_accounts`. This is the part Vault does not have; its schema says so outright. Provisioning follows [`../USER_PROVISIONING.md`](../USER_PROVISIONING.md). |
|
|
|
|
## The one thing that is not like Vault
|
|
|
|
**Vaultwarden speaks a host-agnostic bearer protocol. Linkwarden is a Next.js app using NextAuth.**
|
|
|
|
That difference is the whole of the difficulty. NextAuth issues host-scoped session cookies, checks CSRF
|
|
against the host, and hands clients absolute URLs built from `NEXTAUTH_URL`. Measured on the live
|
|
instance rather than inferred — a sign-in posted to `100.64.0.1:3000` answers with:
|
|
|
|
```json
|
|
{"url":"https://linkwarden.home.pastilhas.eu/api/v1/auth/signin?csrf=true"}
|
|
```
|
|
|
|
So **serving the extension through Officer requires `NEXTAUTH_URL` to name the address Officer serves it
|
|
at.** That is a change to the container, not to this plugin, and it is a prerequisite rather than a
|
|
preference: leave it and the extension is bounced out of the proxy mid-login, to a host that in this case
|
|
does not even answer.
|
|
|
|
The API half is unaffected. `/api/v1/*` accepts `Authorization: Bearer <access token>` and never touches
|
|
NextAuth, so the panel — and anything an agent does — works either way. Only the extension's *login*
|
|
depends on it.
|
|
|
|
## Verified against the live instance
|
|
|
|
Linkwarden **v2.16.2**, 2026-09-05. Every status below was observed, not read in a doc.
|
|
|
|
| Route | Without a token | With a token |
|
|
| --- | --- | --- |
|
|
| `/api/v1/dashboard` | 401 | 200 |
|
|
| `/api/v1/collections` | 401 | 200 |
|
|
| `/api/v1/tags` | 401 | 200 |
|
|
| `/api/v1/links` | 401 | 200 |
|
|
| `/api/v1/users` | 401 | 200 — the admin surface, used for provisioning |
|
|
|
|
Shapes worth knowing before reading the code:
|
|
|
|
- **collections** — nested through `parentId`, with `isPublic`, `ownerId` and `members`.
|
|
- **tags** — paginated, `{ tags, nextCursor }`, unlike the others.
|
|
- **links** — carry the archive: `textContent` and `readable` (extracted text), plus `preview`, `image`,
|
|
`pdf` and `monolith`, with `lastPreserved`. `textContent` is the reason this is worth more than a
|
|
bookmark bar: it makes a saved link something that can be searched and quoted rather than re-opened.
|
|
|
|
## State
|
|
|
|
Foundation only. `manifest.ts`, `db/schema.ts` and this file. The sidecar, the routers, provisioning and
|
|
the panel are not written yet.
|