From c5bfc80d6ccb59ee3b5b2390d7c8328f291673e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 20:21:06 +0100 Subject: [PATCH] the foundation: where the instance is, and who is who on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 2 ++ README.md | 64 ++++++++++++++++++++++++++++++++++ db/schema.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.ts | 71 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 db/schema.ts create mode 100644 manifest.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..603cfaa --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# 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 ` 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. diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..8a47823 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,97 @@ +import { pgTable, integer, text, timestamp, boolean, check, index } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from 'officerdb/auth/schema'; + +// Officer's server-side state for a self-hosted Linkwarden. Officer stores no bookmarks: Linkwarden owns +// those. What lives here is WHERE the instance is, and WHO on this Officer is who on that Linkwarden. +// Every secret column is AES-256-GCM encrypted at rest (officerdb/crypto). + +/** + * Where this install's Linkwarden lives. ONE ROW, ever — `id` is pinned to 1 by a CHECK. + * + * The reasoning is Vault's, and it is worth not re-deriving: a plugin cannot ask the owner to edit `.env` + * and restart a process, `.env` is auto-loaded into every pm2 process so a value one sidecar needs is + * readable from every other one, and a variable read at module load cannot change without a restart. + * Installing the plugin and pointing it at a server has to be enough. + * + * Server-wide rather than per-user because the owner runs one Linkwarden. That is a different question + * from which ACCOUNT a person uses on it, which is the table below — and the thing Vault explicitly left + * for later. + */ +export const linkwardenConfig = pgTable( + 'linkwarden_config', + { + /** Always 1. A single-row table wants to say so structurally, or a second row arrives eventually. */ + id: integer('id').primaryKey().default(1), + /** Base URL, no trailing slash (normalised on write). Null before the owner has pointed it anywhere. */ + url: text('url'), + /** + * The admin access token, used ONLY to provision accounts — `plugins/USER_PROVISIONING.md`. + * + * Separate from any user's token and never injected on a user's request: this one can list and create + * accounts, so it is reachable from exactly one place (`api/accounts.ts`, owner-only) and is not what + * the proxy holds. + */ + adminToken: text('admin_token'), // encrypted + /** + * Whether to serve at all. Distinct from `url` being set: the owner may want to stop exposing an + * instance without forgetting where it is. + */ + enabled: boolean('enabled').notNull().default(true), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [check('ck_linkwarden_config_singleton', sql`${table.id} = 1`)], +); + +/** + * Who an Officer account is on that Linkwarden. + * + * ── Why a mapping and not one shared login ── + * + * Linkwarden has its own users, its own collections and its own sharing. One account shared between + * everyone on an Officer would put every member's reading in one pile with no way to unpick it, and would + * make "revoke this person" mean "change the password for everybody". A row per user is what makes a + * member's bookmarks theirs, and removing them a delete rather than a negotiation. + * + * This 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 the contract it follows + * is `plugins/USER_PROVISIONING.md`: check before create, force a password change, verify by logging in. + * + * ── The token is the whole point ── + * + * With it, a proxied request carries the mapped user's own credential and Linkwarden decides what they + * may see. Without it the platform would have to be the thing that decides, which is a permission model + * re-implemented on top of one that already exists and is already right. + */ +export const linkwardenAccounts = pgTable( + 'linkwarden_accounts', + { + userId: integer('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + /** The id Linkwarden knows them by. Kept so a rename on either side cannot break the mapping. */ + linkwardenUserId: integer('linkwarden_user_id').notNull(), + /** Their username there, for showing in the panel — the id is what anything reads. */ + username: text('username').notNull(), + /** + * That account's own access token, minted at provisioning. + * + * Null is a real state and not an error: an account can exist before a token has been issued for it, + * and the panel says so rather than looking broken. + */ + accessToken: text('access_token'), // encrypted + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + /* + Ordinary, NOT unique. + + Two Officer accounts pointing at one Linkwarden account is a mistake, but it is the OWNER's mistake + to make and un-make: a unique index turns it into a failed insert with no explanation at the moment + of provisioning, and the m3e-canvas review this week is a fresh reminder of what a constraint chosen + for tidiness does to the ordinary path. `api/accounts.ts` checks and refuses with a sentence. + */ + index('linkwarden_accounts_by_linkwarden_user').on(table.linkwardenUserId), + ], +); diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..a830255 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,71 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Linkwarden — your bookmarks, behind an Officer session. + * + * ── The model, which is Vault's ── + * + * One Linkwarden per Officer install, pointed at by the owner. Officer does not install it and does not + * store bookmarks: Linkwarden owns the data, and this plugin owns the CREDENTIAL and the door. Where + * Vault brokers one account, this maps EVERY Officer user onto a Linkwarden account of their own — see + * `db/schema.ts`, and `plugins/USER_PROVISIONING.md` for the contract that governs creating one. + * + * ── What is different from Vault, and it is not small ── + * + * Vaultwarden speaks a host-agnostic bearer protocol, so its sidecar can be a dumb pass-through and a + * Bitwarden client does not care what address it is reached at. Linkwarden is a Next.js app using + * NextAuth: it 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 `{"url":"https://linkwarden.home.pastilhas.eu/api/v1/auth/signin?csrf=true"}`. So serving the + * browser extension through Officer requires that `NEXTAUTH_URL` 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: without it the extension is bounced out of the proxy mid-login. + * + * The API half is unaffected. `/api/v1/*` accepts `Authorization: Bearer ` and never + * touches NextAuth, so the panel and anything an agent does work regardless of the above. + * + * ── 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: 'Linkwarden', + summary: 'Your bookmarks, behind an Officer session — saved, archived and searchable', + tags: ['Personal', 'Degoogling'], + icon: 'Bookmark', + color: '#0ea5e9', + + permissions: [ + { + key: 'linkwarden', + label: 'Linkwarden', + description: 'Your saved links, collections and their archived pages', + /* + Reading and saving links is every user's; pointing the install at a server and creating accounts + on it is not. `/config` holds the URL 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. + + Same reasoning as Vault's `/bw`: a stock Linkwarden extension carries its own credential and has + nowhere to put an Officer JWT. It is the same exposure through one address instead of two, and it is + what lets Linkwarden stop being reachable on its own. + + ONE prefix and no root claims, which is where this is simpler than Vault. A Bitwarden client derives + `/api`, `/identity`, `/icons` and `/notifications` from a bare server URL and therefore needed four + root-level mounts; the Linkwarden extension is given a base and appends `/api/v1/...` beneath it, so + everything the protocol needs already sits under one declared path. + */ + unauthenticatedPaths: ['/lw/*'], +};