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.
98 lines
4.9 KiB
TypeScript
98 lines
4.9 KiB
TypeScript
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),
|
|
],
|
|
);
|