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 Karakeep. Officer stores no bookmarks: Karakeep owns // those. What lives here is WHERE the instance is, and WHO on this Officer is who on that Karakeep. // Every secret column is AES-256-GCM encrypted at rest (officerdb/crypto). /** * Where this install's Karakeep lives. ONE ROW, ever — `id` is pinned to 1 by a CHECK. * * A table rather than an env var, for the reasons Vault's schema sets out: a plugin cannot ask the owner * to edit a file 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. */ export const karakeepConfig = pgTable( 'karakeep_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. * * ── The address the SIDECAR dials, which is not the one clients are told ── * * They come apart whenever Officer and the upstream share a host, and on this install they do. A * browser elsewhere resolves the machine's name over the tailnet; ON that machine the same name * resolves to 127.0.1.1, where nothing is bound. Measured during the Linkwarden build: the advertised * name answered from every host except the one the sidecar runs on. * * So this holds the loopback address. What a client is told is a separate question, and with Karakeep * a much smaller one — its API key is host-agnostic, so there is no third value to keep in step the * way `NEXTAUTH_URL` had to be. */ url: text('url'), /** * An admin API key, used ONLY to provision accounts — `plugins/USER_PROVISIONING.md`. * * Separate from any user's key and never injected on a user's request: this one can list and delete * accounts, so it is reachable from exactly one place (`api/accounts.ts`, owner-only) and is not what * the proxy holds. */ adminApiKey: text('admin_api_key'), // 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_karakeep_config_singleton', sql`${table.id} = 1`)], ); /** * Who an Officer account is on that Karakeep. * * ── Why a mapping and not one shared login ── * * Karakeep has its own users, lists and sharing. One account shared between everyone 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. * * ── The key is the whole point ── * * With it, a proxied request carries the mapped user's own credential and Karakeep 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. * * Unlike the Linkwarden attempt, nothing here has to be pasted by hand: `apiKeys.exchange` is a public * procedure that turns a username and password into a key, so Officer creates the account, chooses the * password, and mints the key itself. */ export const karakeepAccounts = pgTable( 'karakeep_accounts', { userId: integer('user_id') .primaryKey() .references(() => users.id, { onDelete: 'cascade' }), /** The id Karakeep knows them by. Kept so a rename on either side cannot break the mapping. */ karakeepUserId: text('karakeep_user_id').notNull(), /** What they log in as there — Karakeep identifies users by email. */ email: text('email').notNull(), /** * That account's own API key. * * Null is a real state and not an error: an account can exist before a key has been minted for it, * and the panel says so rather than looking broken. */ apiKey: text('api_key'), // encrypted /** Karakeep's own id for the key, so revoking it later does not mean guessing which one. */ apiKeyId: text('api_key_id'), 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 Karakeep account is a mistake, but it is the owner's to make and un-make. A unique index turns it into a failed insert with no explanation at the moment of provisioning; `api/accounts.ts` checks and refuses with a sentence instead. */ index('karakeep_accounts_by_karakeep_user').on(table.karakeepUserId), ], );