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), ], );