From 58688365f054e782edbf14eba1fe1ebfeafb853c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 21:36:19 +0100 Subject: [PATCH] the plugin, written against karakeep instead of linkwarden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same model as Vault: one instance per install, a sidecar that is the only thing knowing where it is, two doors - one authenticated for our own UI that injects the caller's credential, one unauthenticated for a client that carries its own - and an account per Officer user. ── Why this upstream ── Linkwarden authenticates its extension through NextAuth: host-scoped cookies, CSRF checked against the host, absolute URLs baked from NEXTAUTH_URL. Proxying that means telling the upstream the address the CLIENT uses, which is not the address the SIDECAR dials, and getting it wrong bounces the extension out of the proxy mid-login. It cost a NEXTAUTH_URL change, a container recreate, a lost csrf cookie, and it retired the upstream's own web login as the price of going through Officer. Karakeep's extension holds an API key and sends it as a header. Nothing is host-pinned, so none of that exists. And provisioning needs nothing pasted: users.create and apiKeys.exchange are both public procedures, so Officer creates the account, chooses the password, and mints the key itself - where Linkwarden could only take a key a person made by hand. ── Carried over deliberately ── Two fixes from the Linkwarden build, both upstream-agnostic and both found by running it rather than reading it. `content-encoding` stripped from proxied responses, because fetch decompresses the body while the headers still claim gzip - which hides until the first response big enough to compress. And Set-Cookie copied with append rather than set, because it is the one header that legitimately repeats. Karakeep should never need the second one; it is here because a proxy correct only for the traffic its author tested is a trap for whoever changes the upstream later. Also carried: the unauthenticated door is hand-written rather than the shared createSidecarProxy router, which does ctx.get("user").id and 500s where there is no session. Written against source, not yet run. The response shapes in the panels come from packages/api/routes/ and are the first thing to check against a live instance. No provisioning yet. --- .gitignore | 2 + README.md | 75 +++++++++++++++++ api/router.ts | 181 +++++++++++++++++++++++++++++++++++++++++ db/queries.ts | 147 +++++++++++++++++++++++++++++++++ db/schema.ts | 107 ++++++++++++++++++++++++ manifest.ts | 74 +++++++++++++++++ sidecar/index.ts | 123 ++++++++++++++++++++++++++++ sidecar/upstream.ts | 130 +++++++++++++++++++++++++++++ web/BookmarksPanel.tsx | 174 +++++++++++++++++++++++++++++++++++++++ web/ListsPanel.tsx | 111 +++++++++++++++++++++++++ web/SettingsForm.tsx | 131 +++++++++++++++++++++++++++++ web/layout.ts | 13 +++ web/panels.ts | 15 ++++ 13 files changed, 1283 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/router.ts create mode 100644 db/queries.ts create mode 100644 db/schema.ts create mode 100644 manifest.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/upstream.ts create mode 100644 web/BookmarksPanel.tsx create mode 100644 web/ListsPanel.tsx create mode 100644 web/SettingsForm.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.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..3cfb8a6 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Karakeep + +Your bookmarks, behind an Officer session. One self-hosted [Karakeep](https://karakeep.app) per Officer +install, pointed at by the owner, with every Officer user mapped onto an account of their own. + +Officer stores no bookmarks. Karakeep owns those. What this plugin owns is the **credential** and the +**door**. + +## The model + +It is the Vault model: + +| | | +| --- | --- | +| **One instance per install** | `karakeep_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-karakeep` reads the URL from Postgres with a short TTL and reverse-proxies. Nothing else knows where Karakeep is. | +| **Two doors** | An authenticated one for Officer's own UI, which injects the mapped user's API key so the device never holds one. And `/api/karakeep/kk`, unauthenticated, for the browser extension — which carries its own key. | +| **An account per user** | `karakeep_accounts`, provisioned per [`../USER_PROVISIONING.md`](../USER_PROVISIONING.md). | + +## Why Karakeep, when this was first built against Linkwarden + +Worth recording, because the plugin is otherwise the same shape. + +Linkwarden authenticates its extension through **NextAuth**: host-scoped session cookies, CSRF checked +against the host, and absolute URLs baked from `NEXTAUTH_URL`. Proxying that means telling the upstream +the address the *client* uses, which is not the address the *sidecar* dials. Getting it wrong bounces the +extension out of the proxy mid-login. It cost a `NEXTAUTH_URL` change, a container recreate, a lost csrf +cookie, and it retired the upstream's own web login as the price of going through Officer. + +Karakeep's extension holds an **API key** and sends it as a header. Nothing is host-pinned, so none of +that exists — the same key works at any address and the upstream stays reachable on its own. + +Three concrete differences, all from source: + +| | Linkwarden | Karakeep | +| --- | --- | --- | +| Extension auth | username/password → session, plus NextAuth cookie routes | `apiKey` in settings, sent as a header | +| Host pinning | `NEXTAUTH_URL` must name the client-facing address | none | +| Custom headers | not supported | `customHeaders: z.record(string, string)` | +| Extension path | `${url}/api/v1/…` across the namespace | `${address}/api/trpc` | +| Provisioning | account creation yes; key minting needs a session | `users.create` + `apiKeys.exchange`, both public | + +That last row is why nothing has to be pasted by hand: Officer creates the account, chooses the +password, and exchanges it for a key itself. + +## A bare Officer origin + +Not declared yet, and possible. The extension takes arbitrary `customHeaders`, so `protocolHeaders` +could route a bare `https:///` to this plugin the way Bitwarden's client headers route to Vault. +Left until the path form is proven, because it asks the owner to configure a header whose purpose is +invisible from where they are typing it. + +## Two things carried over from the Linkwarden build + +Both were bugs found by running it, and both are upstream-agnostic: + +- **`content-encoding` must be stripped from a proxied response.** `fetch` decompresses the body but the + headers still claim gzip, so the client tries to gunzip plain JSON. It hides well — a 401 body is too + small to compress, so every unauthenticated probe passes and the first *real* request is the first to + fail. +- **`Set-Cookie` must be copied with `append`, not `set`.** It is the one header that legitimately + repeats. Karakeep should never need this; it is here because a proxy that is correct only for the + traffic its author happened to test is a trap for whoever changes the upstream later. + +## State + +Written against the source, **not yet run**. The response shapes below come from +`packages/api/routes/`, and are the first thing to verify against a live instance: + +- `GET /api/v1/lists` → `{ lists: [...] }` +- `GET /api/v1/bookmarks` → `{ bookmarks: [...], nextCursor }` +- `GET /api/v1/bookmarks/search?q=` → the same, paginated +- `GET /api/v1/lists/:listId/bookmarks` → the same + +No provisioning yet — `api/accounts.ts` is the next piece. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..e16a0c5 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,181 @@ +import { createRouter } from '@@/create-router'; +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { getKarakeepConfig, setKarakeepConfig, getAccount } from '../db/queries'; +import { stripHopByHop, stripResponseHeaders } from '../sidecar/upstream'; + +/** + * Mounted at `/api/karakeep`. + * + * Three things live here and they are worth telling apart, because two of them look the same from the + * outside and are not: + * + * `/lw/*` the extension's door. Unauthenticated, forwarded verbatim, credential is the caller's. + * `/api/*` OUR door. Officer session required; we swap in the caller's Karakeep token. + * `/config` where the instance is. Owner only, by the manifest. + */ +export const router = createRouter(); + +/* + The extension's door. + + `proxiedSubPath` is the load-bearing argument. `registerProxiedPrefix` tells the body parser to leave a + prefix alone — right for forwarded traffic, and required for uploads — but this plugin serves its own + routes under the same prefix, and those need their bodies parsed. Claiming the whole of + `/api/karakeep` would make every field on `/config` read as missing, which is the failure caldav hit + on the day it was extracted. So the proxy claims `/lw` and nothing else. +*/ +const proxy = createSidecarProxy({ + name: 'karakeep', + prefix: '/api/karakeep', + proxiedSubPath: '/kk', + permissionKey: 'karakeep', + // Archiving a page can take a while, and the extension waits on it. + timeoutSeconds: 120, +}); + +/* + ── The factory's ROUTER is not used, and that is the whole reason this is written out ── + + `createSidecarProxy` is for AUTHENTICATED proxying: its forwarder does `ctx.get('user').id`, because + every other sidecar behind it is reached by somebody with an Officer session. This door is declared in + `unauthenticatedPaths`, so there is no user, and mounting the factory's router here answered every + request with `TypeError: undefined is not an object` as a 500. Measured on alpha, not reasoned about. + + Vault hand-writes its public router for exactly this reason and I should have followed it rather than + reaching for the shared thing because it was shared. + + What the factory IS still used for, and why it is still constructed: `getHttpUrl()`, so the port the + sidecar announced is discovered in one place; and `registerProxiedPrefix`, which runs at construction + and tells the body parser to leave `/api/karakeep/kk` alone. Both matter, and neither is the router. +*/ +router.all('/kk/*', async (ctx) => { + const base = proxy.getHttpUrl(); + if (!base) return ctx.text('the Karakeep sidecar is not running', 503); + + const url = new URL(ctx.req.url); + const subpath = url.pathname.slice('/api/karakeep/kk'.length) || '/'; + + /* + Authorization is FORWARDED, not replaced — the opposite of the door below. + + The client's own credential is the only one in play here, and Karakeep is the only thing entitled + to judge it. Officer adds no security to a stock extension because it has nothing to check: this is + the same exposure through one address instead of two, which is what lets Karakeep stop listening + on its own. + */ + const hasBody = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'; + const init: RequestInit & { duplex?: 'half' } = { + method: ctx.req.method, + headers: stripHopByHop(ctx.req.raw.headers), + redirect: 'manual', + ...(hasBody ? { body: ctx.req.raw.body, duplex: 'half' as const } : {}), + }; + + try { + const upstream = await fetch(`${base}${subpath}${url.search}`, init); + return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers) }); + } catch { + return ctx.text('Karakeep unreachable', 502); + } +}); + +// ── where the instance is ──────────────────────────────────────────────────────────────────────── + +router.get('/config', async (ctx) => { + const config = await getKarakeepConfig(); + // The admin token is never returned, only whether one is set — see `getAdminToken` for why it is read + // through a separate function rather than being a field on this. + return ctx.json({ url: config.url, enabled: config.enabled, updatedAt: config.updatedAt }); +}); + +router.put('/config', async (ctx) => { + const body = (await ctx.req.json().catch(() => null)) as { + url?: string | null; + enabled?: boolean; + adminApiKey?: string | null; + } | null; + if (!body) return ctx.json({ error: 'expected a JSON object' }, 400); + + if (body.url !== undefined && body.url !== null && !/^https?:\/\//i.test(body.url.trim())) { + return ctx.json({ error: 'the address must start with http:// or https://' }, 400); + } + + const config = await setKarakeepConfig(body); + return ctx.json({ url: config.url, enabled: config.enabled, updatedAt: config.updatedAt }); +}); + +/** + * Can we actually reach it, and does the credential work? + * + * Separate from `/config` because "the owner typed an address" and "there is a Karakeep there" are + * different facts, and a settings panel that conflates them makes a typo look like an outage. Asked of + * the sidecar rather than from here: it is the thing that will have to reach it, and its answer is the + * one that matters. + */ +router.get('/config/health', async (ctx) => { + const base = proxy.getHttpUrl(); + if (!base) return ctx.json({ ok: false, reason: 'sidecar not running' }, 503); + try { + const probe = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(8000) }); + return ctx.json(await probe.json(), probe.status as 200); + } catch { + return ctx.json({ ok: false, reason: 'sidecar unreachable' }, 502); + } +}); + +// ── our own door ───────────────────────────────────────────────────────────────────────────────── + +/** + * Karakeep's API, as the calling Officer user. + * + * ── Why this exists next to `/lw`, which already forwards ── + * + * `/lw` forwards the caller's own credential, which is right for the extension and impossible for us: a + * browser holding an Officer session has no Karakeep token and must never be given one. So this route + * looks up who is calling, finds their mapped account, and puts THEIR token on the request. The device + * holds an Officer session and nothing else, which is the same property the Vault plugin exists to give. + * + * The permission model stays Karakeep's. Officer decides whose token goes on; Karakeep decides what + * that token may see. Re-implementing the second half here would be a second answer able to disagree + * with the first. + */ +router.all('/api/*', async (ctx) => { + const userId = ctx.get('user')?.id; + if (!userId) return ctx.json({ error: 'unauthenticated' }, 401); + + const base = proxy.getHttpUrl(); + if (!base) return ctx.json({ error: 'the Karakeep sidecar is not running' }, 503); + + const account = await getAccount(userId); + if (!account) { + // Named rather than a bare 403: "you have no Karakeep account" is something the owner can fix, and + // a message that says so is the difference between a support question and a self-service one. + return ctx.json({ error: 'no Karakeep account is mapped to you — the owner can create one in Settings' }, 403); + } + if (!account.apiKey) { + return ctx.json({ error: 'your Karakeep account has no API key yet' }, 403); + } + + const url = new URL(ctx.req.url); + // `/api/karakeep/api/v1/links` is Karakeep's `/api/v1/links`. + const subpath = url.pathname.slice('/api/karakeep'.length) || '/'; + + const headers = new Headers(ctx.req.raw.headers); + headers.delete('cookie'); // an Officer session cookie means nothing upstream and should not travel + headers.set('Authorization', `Bearer ${account.apiKey}`); + + const hasBody = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'; + const init: RequestInit & { duplex?: 'half' } = { + method: ctx.req.method, + headers, + redirect: 'manual', + ...(hasBody ? { body: ctx.req.raw.body, duplex: 'half' as const } : {}), + }; + + try { + const upstream = await fetch(`${base}${subpath}${url.search}`, init); + return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers) }); + } catch { + return ctx.json({ error: 'Karakeep unreachable' }, 502); + } +}); diff --git a/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..dd94298 --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,147 @@ +import { eq } from 'drizzle-orm'; +import { db } from 'officerdb/db'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; +import { karakeepConfig, karakeepAccounts } from './schema'; + +// Karakeep store access. Callers deal in PLAINTEXT — encryption to and from at-rest ciphertext happens +// here, so neither the proxy nor the provisioner touches crypto directly. The key purpose is +// `karakeep`, minted on first use by the secret store (docs/secret-store.md). + +// ── Instance configuration (server-wide, one row) ── + +export type KarakeepConfig = { url: string | null; enabled: boolean; updatedAt: Date | null }; + +const EMPTY_CONFIG: KarakeepConfig = { url: null, enabled: true, updatedAt: null }; + +/** + * Where this install's Karakeep is, or `url: null` when the owner has not pointed it anywhere yet. + * + * Never throws and never creates the row. A missing row and an unset URL are the same answer to the only + * question anyone asks here, and an install nobody has configured is an ordinary state rather than an + * error. The admin token is deliberately NOT returned — see `getAdminApiKey`. + */ +export async function getKarakeepConfig(): Promise { + const [row] = await db.select().from(karakeepConfig).where(eq(karakeepConfig.id, 1)); + if (!row) return EMPTY_CONFIG; + return { url: row.url, enabled: row.enabled, updatedAt: row.updatedAt }; +} + +/** Trailing slash removed on write, so `${url}/api/v1/...` can never double the separator. */ +const normaliseUrl = (raw: string): string => raw.trim().replace(/\/+$/, ''); + +/** + * Point this install at a Karakeep, or change which one. + * + * The URL is stored as given (normalised) and NOT probed. Reachability is a separate question with its + * own route, and a setter that refused an unreachable address would make configuring an instance that is + * temporarily down impossible. + */ +export async function setKarakeepConfig(patch: { + url?: string | null; + enabled?: boolean; + adminApiKey?: string | null; +}): Promise { + const values = { + id: 1, + ...(patch.url !== undefined ? { url: patch.url === null ? null : normaliseUrl(patch.url) } : {}), + ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}), + ...(patch.adminApiKey !== undefined + ? { adminApiKey: patch.adminApiKey === null ? null : await encryptSecret('karakeep', patch.adminApiKey) } + : {}), + updatedAt: new Date(), + }; + + await db + .insert(karakeepConfig) + .values(values) + .onConflictDoUpdate({ target: karakeepConfig.id, set: values }); + + return getKarakeepConfig(); +} + +/** + * The admin token, decrypted. Read by provisioning and by nothing else. + * + * Separate from `getKarakeepConfig` on purpose: that one is handed to the settings panel and to the + * sidecar, and neither has any business holding a credential that can create accounts. A single function + * that returned everything would make the careful thing the one you have to remember to do. + */ +export async function getAdminApiKey(): Promise { + const [row] = await db.select().from(karakeepConfig).where(eq(karakeepConfig.id, 1)); + if (!row?.adminApiKey) return null; + return decryptSecret('karakeep', row.adminApiKey); +} + +// ── Account mapping (one row per Officer user) ── + +export type KarakeepAccount = { + userId: number; + karakeepUserId: string; + email: string; + /** Null when an account exists but no key has been minted for it yet. A real state, not an error. */ + apiKey: string | null; + apiKeyId: string | null; +}; + +/** Who this Officer user is on Karakeep, or null when nobody has mapped them. */ +export async function getAccount(userId: number): Promise { + const [row] = await db.select().from(karakeepAccounts).where(eq(karakeepAccounts.userId, userId)); + if (!row) return null; + return { + userId: row.userId, + karakeepUserId: row.karakeepUserId, + email: row.email, + apiKey: row.apiKey ? await decryptSecret('karakeep', row.apiKey) : null, + apiKeyId: row.apiKeyId, + }; +} + +/** Every mapping, for the owner's account screen. Keys are NOT included — only whether one exists. */ +export async function listAccounts(): Promise< + { userId: number; karakeepUserId: string; email: string; hasKey: boolean }[] +> { + const rows = await db.select().from(karakeepAccounts); + return rows.map((row) => ({ + userId: row.userId, + karakeepUserId: row.karakeepUserId, + email: row.email, + hasKey: !!row.apiKey, + })); +} + +/** Map an Officer user onto a Karakeep account, or replace the mapping. Upsert on `userId`. */ +export async function setAccount(account: { + userId: number; + karakeepUserId: string; + email: string; + apiKey?: string | null; + apiKeyId?: string | null; +}): Promise { + const values = { + userId: account.userId, + karakeepUserId: account.karakeepUserId, + email: account.email, + ...(account.apiKey !== undefined + ? { apiKey: account.apiKey === null ? null : await encryptSecret('karakeep', account.apiKey) } + : {}), + ...(account.apiKeyId !== undefined ? { apiKeyId: account.apiKeyId } : {}), + updatedAt: new Date(), + }; + + await db.insert(karakeepAccounts).values(values).onConflictDoUpdate({ target: karakeepAccounts.userId, set: values }); +} + +/** + * Forget the mapping. Does NOT delete the Karakeep account. + * + * Deliberate: Officer created it but does not own what is in it, and a person removed from an Officer + * install should not silently lose a library of saved reading. Deleting it there is a separate act, done + * by somebody who means it. + */ +export async function forgetAccount(userId: number): Promise { + const rows = await db + .delete(karakeepAccounts) + .where(eq(karakeepAccounts.userId, userId)) + .returning({ userId: karakeepAccounts.userId }); + return rows.length > 0; +} diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..a2b0e2c --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,107 @@ +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), + ], +); diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..94e4d86 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,74 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Karakeep — your bookmarks, behind an Officer session. + * + * ── The model, which is Vault's ── + * + * One Karakeep per Officer install, pointed at by the owner. Officer stores no bookmarks: Karakeep owns + * those. What this plugin owns is the CREDENTIAL and the door. Where Vault brokers one account, this maps + * every Officer user onto a Karakeep account of their own — `db/schema.ts`, and + * `plugins/USER_PROVISIONING.md` for the contract that governs creating one. + * + * ── Why Karakeep and not Linkwarden, which this was first built against ── + * + * Worth recording, because the plugin is otherwise the same shape and somebody will wonder. + * + * Linkwarden authenticates its extension through NextAuth: host-scoped session cookies, CSRF checked + * against the host, and absolute URLs baked from `NEXTAUTH_URL`. Proxying that means the upstream has to + * be told the address the CLIENT uses, which is not the address the sidecar dials, and getting it wrong + * bounces the extension out of the proxy mid-login. It cost a NEXTAUTH_URL change, a lost csrf cookie and + * a container recreate, and it retired the upstream's own web login as the price of going through Officer. + * + * Karakeep's extension holds an API KEY and sends it as a header. Nothing is host-pinned, so none of the + * above exists: the same key works at any address, and the upstream stays reachable on its own. + * + * ── The extension, and why one prefix is enough ── + * + * It calls `${address}/api/trpc`, so pointing it at `https:///api/karakeep/kk` puts everything + * it needs under one declared path. No root-path claims, and no header routing. + * + * A bare Officer origin — the Bitwarden arrangement, where you paste the Officer URL and nothing else — + * is possible here and is NOT declared yet. It would need `protocolHeaders`, and Karakeep's extension can + * carry one because its settings take arbitrary `customHeaders`. That is a real option rather than a + * wish, and it is left until the path form is proven, because it asks the owner to configure a header + * whose purpose is invisible from where they are typing it. + * + * ── 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: 'Karakeep', + summary: 'Your bookmarks, behind an Officer session — saved, archived and searchable', + tags: ['Personal', 'Degoogling'], + icon: 'Bookmark', + color: '#14b8a6', + + permissions: [ + { + key: 'karakeep', + label: 'Karakeep', + description: 'Your saved bookmarks, lists and their archived pages', + /* + Reading and saving is every user's. Pointing the install at a server and creating accounts on it + is not — `/config` holds the address 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. + + A stock Karakeep extension carries its own API key and has nowhere to put an Officer JWT. Same + argument as Vault's `/bw`: it is the same exposure through one address instead of two, and it is what + lets Karakeep stop being reachable on its own. + */ + unauthenticatedPaths: ['/kk/*'], +}; diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..db4d8bc --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,123 @@ +import { createSidecarConnector } from '@@/sidecar/connect'; +import { API_URL } from '@@/officer-url.mjs'; +import { getKarakeepBase, stripHopByHop, stripResponseHeaders, redactPath } from './upstream'; + +// The officer-karakeep sidecar: a singleton process that registers with the API server and owns a +// transparent reverse-proxy to a self-hosted Karakeep. The platform API is a thin forwarder to this; +// ALL knowledge of Karakeep — where it is, whether it is switched on — lives here, which is what lets +// the plugin be a plugin rather than something the platform has to learn about. +// +// ── Pass-through, with one exception that is the point of the plugin ── +// +// Method, path, query, headers, status and both body streams go through verbatim. Nothing is buffered, +// parsed, rewritten or cached. The one exception is the Authorization header on the AUTHENTICATED door: +// the platform has already decided who is calling and swapped in that person's Karakeep token, so a +// device holding an Officer session never holds a Karakeep credential. This process does not do that +// swap and does not know the keys — it forwards what it is handed. See `api/router.ts`. +// +// ── No WebSocket ── +// +// Vault's sidecar proxies one, because Vaultwarden pushes notifications over SignalR. Karakeep is +// request/response over HTTP — its extension and its own UI both poll — so a WebSocket path here would +// be scaffolding for a protocol that does not exist. + +/** Grab an ephemeral free port by briefly binding one and releasing it. */ +function getFreePort(): number { + const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const port = probe.port; + probe.stop(true); + if (port == null) throw new Error('failed to acquire a free port'); + return port; +} + +const PORT = getFreePort(); + +Bun.serve({ + port: PORT, + hostname: '127.0.0.1', + idleTimeout: 120, + + async fetch(req) { + const url = new URL(req.url); + + /* + Ours, not part of the contract. Probes the upstream so "is the sidecar up" and "can it reach + Karakeep" are two answerable questions rather than one ambiguous timeout. + */ + if (url.pathname === '/_health') { + const base = await getKarakeepBase(); + if (!base) return Response.json({ ok: false, reason: 'unconfigured' }, { status: 503 }); + try { + const probe = await fetch(`${base}/api/health`, { + signal: AbortSignal.timeout(5000), + }); + return Response.json({ ok: probe.ok, upstream: probe.status }); + } catch { + return Response.json({ ok: false, reason: 'unreachable' }, { status: 502 }); + } + } + + const base = await getKarakeepBase(); + if (!base) return new Response('Karakeep is not configured', { status: 503 }); + + const target = `${base}${url.pathname}${url.search}`; + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + + const init: RequestInit & { duplex?: 'half' } = { + method: req.method, + headers: stripHopByHop(req.headers), + // `manual`, so a redirect is handed back to the caller exactly as Karakeep wrote it rather than + // being followed here. NextAuth redirects are part of its protocol and a proxy that resolves them + // silently changes what the client sees. + redirect: 'manual', + ...(hasBody ? { body: req.body, duplex: 'half' as const } : {}), + }; + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch (err) { + console.error( + `[karakeep] ${req.method} ${redactPath(url.pathname + url.search)} -> upstream unreachable:`, + err instanceof Error ? err.message : String(err), + ); + return new Response('Karakeep unreachable', { status: 502 }); + } + + return new Response(upstream.body, { + status: upstream.status, + headers: stripResponseHeaders(upstream.headers), + }); + }, +}); + +console.log(`[karakeep] proxy listening on 127.0.0.1:${PORT}`); + +/* + Tell the platform where to find us. + + The port is ephemeral and chosen fresh on every start, so it is not a value anybody configures — it is + reported on connect and the platform routes to whatever the last connect said. That is what makes + restarting this process invisible to the API server, and it is re-sent on every reconnect rather than + once at boot, because the platform may be the side that restarted. +*/ +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'karakeep', + handles: ['karakeep'], + + /* + Required by the connector, and deliberately empty. + + There is no command vocabulary here. Vault has one because it brokers a login; this sidecar only + forwards bytes, and everything that decides anything — which user, which token, whether they may — + happens on the platform side before a request arrives. A command channel would be a second way to + reach a process whose whole value is that it does one thing. + */ + onCommand() {}, + + onConnected() { + connection.send({ type: 'karakeep:server', port: PORT }); + console.log(`[karakeep] reported proxy port ${PORT} to API`); + }, +}); diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts new file mode 100644 index 0000000..6fe59bb --- /dev/null +++ b/sidecar/upstream.ts @@ -0,0 +1,130 @@ +import { getKarakeepConfig } from '../db/queries'; + +// Where Karakeep is, for the officer-karakeep sidecar, plus header hygiene. +// +// ── The URL comes from the database, and can change while this is running ── +// +// `db/schema.ts` says why it is not an environment variable. The consequence here is the interesting +// part: a value that would have been constant for the process lifetime is now something the owner can +// change from a settings panel without restarting anything. +// +// ── Why a cache with a TTL rather than a read per request ── +// +// This sits in front of every proxied byte. A Postgres round trip per request would put the database on +// the hot path of a proxy whose whole design rule is to do as little as possible. Ten seconds is chosen +// against what the value actually is — a URL edited by hand, roughly never, and then wanted immediately. +// Imperceptible to somebody who just pressed Save, and free the rest of the time. +// +// The TTL is the WHOLE mechanism. There is no push: `sidecar-registry.ts` carries events from a sidecar +// to the platform, not commands the other way, so the config route has nothing to call. Building that +// channel for a cache hint would be a channel built for the wrong reason. +// +// A failed read keeps serving the last known-good value rather than dropping to null, because "cannot +// reach Postgres" and "the owner never configured this" look identical on screen and are not the same. + +const TTL_MS = 10_000; + +type Cached = { url: string | null; enabled: boolean; at: number }; +let cache: Cached | null = null; +let inflight: Promise | null = null; + +async function read(): Promise { + try { + const config = await getKarakeepConfig(); + cache = { url: config.url, enabled: config.enabled, at: Date.now() }; + return cache; + } catch (err) { + console.error(`[karakeep] could not read config: ${err instanceof Error ? err.message : String(err)}`); + if (cache) return { ...cache, at: Date.now() }; + return { url: null, enabled: true, at: Date.now() }; + } +} + +async function current(): Promise { + if (cache && Date.now() - cache.at < TTL_MS) return cache; + // One read at a time. Without this, every request under load misses the same expired entry and fires + // its own query — the stampede that makes a cache slower than no cache. + inflight ??= read().finally(() => { + inflight = null; + }); + return inflight; +} + +/** The base URL to forward to, or null when unconfigured or switched off. No trailing slash. */ +export async function getKarakeepBase(): Promise { + const { url, enabled } = await current(); + return enabled ? url : null; +} + +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'host', +]); + +/** Copy headers, dropping only hop-by-hop and `proxy-*` ones. Authorization and cookies pass through. */ +export function stripHopByHop(src: Headers): Headers { + const out = new Headers(); + src.forEach((value, key) => { + const k = key.toLowerCase(); + if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; + out.set(key, value); + }); + return out; +} + +/** + * Headers for a response we are handing back, which is NOT the same as headers for a request. + * + * ── `content-encoding` has to go, and finding out why cost a 502 ── + * + * `fetch` decompresses a gzipped response body before anyone sees it. The headers it returns still say + * `content-encoding: gzip`, so copying them onto the decoded bytes tells the client to gunzip plain + * JSON — `Decompression error: ZlibError`, surfacing as a 502 from the catch below. + * + * It hides well. A 401 body is a few bytes and is not compressed, so every unauthenticated probe passes + * and the first REAL request is the one that fails. Measured exactly that way on alpha: the door + * answered 401 correctly without a token and 502 with one. + * + * `content-length` goes with it for the same reason — it describes the compressed length, and the body + * being handed on is not that length. + */ +export function stripResponseHeaders(src: Headers): Headers { + const out = stripHopByHop(src); + out.delete('content-encoding'); + out.delete('content-length'); + + /* + ── `Set-Cookie` is the one header that legitimately repeats, and `.set()` keeps only the last ── + + `stripHopByHop` copies with `out.set(key, value)`, which OVERWRITES. Fine for every other header and + wrong for this one: a response setting three cookies arrives as one. + + Karakeep authenticates with an API key and should never need this — which is exactly why it is here. + The sister plugin built against Linkwarden lost a csrf cookie this way and produced a sign-in that + looped with nothing in any log to explain it. A proxy that is correct only for the traffic its author + happened to test is a trap for whoever changes the upstream later. + + `getSetCookie()` returns the values unjoined, which matters because a cookie's `Expires` contains a + comma and naive comma-splitting corrupts it. + */ + out.delete('set-cookie'); + for (const cookie of src.getSetCookie()) out.append('set-cookie', cookie); + + return out; +} + +/* + Paths are logged; tokens are not. + + Karakeep puts an access token in a query string in more than one place, and a NextAuth callback + carries a csrf token the same way. None of it belongs in a log that gets pasted into a chat. +*/ +const REDACT = /([?&](access_token|token|csrfToken|code)=)[^&]*/gi; +export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1'); diff --git a/web/BookmarksPanel.tsx b/web/BookmarksPanel.tsx new file mode 100644 index 0000000..eee0a8f --- /dev/null +++ b/web/BookmarksPanel.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { Input } from '@/components/ui/input'; +import { ExternalLink, FileText, StickyNote, Image as ImageIcon } from 'lucide-react'; +import { ALL } from './ListsPanel'; + +/** + * Your saved bookmarks, as the calling Officer user. + * + * Every request goes through `/api/karakeep/api/*` — the authenticated door, which finds the caller's + * mapped account and puts THEIR key on the request. This panel holds no credential and could not send + * one if it wanted to, which is the property the whole plugin exists for. + * + * ── Search is Karakeep's, not ours ── + * + * `/bookmarks/search?q=` searches titles, notes, tags and the archived text. Re-implementing it here + * would mean pulling every bookmark to the browser to filter it, and would still only see the fields we + * happened to fetch. + */ + +/** + * A bookmark is a discriminated union on `content.type` — a link, a note, or an asset. + * + * Officer reads the minimum: enough to draw a row and open the thing. Everything else stays Karakeep's, + * which is what keeps an upstream field addition from being a change here. + */ +type Bookmark = { + id: string; + title?: string | null; + createdAt?: string; + tags?: { id: string; name: string }[]; + content?: + | { type: 'link'; url: string; title?: string | null; description?: string | null } + | { type: 'text'; text?: string } + | { type: 'asset'; assetType?: string; fileName?: string | null }; +}; + +/** Paginated, and the cursor is ignored for now — the first page is a screenful. */ +type Page = { bookmarks?: Bookmark[]; nextCursor?: string | null }; + +export const BookmarksPanel = () => { + const client = useClient(); + // `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list. + const clientRef = useRef(client); + clientRef.current = client; + + const [params] = useSearchParams(); + const list = params.get('list') ?? ALL; + + const [query, setQuery] = useState(''); + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async (listId: string, search: string) => { + /* + Three different endpoints, because Karakeep models them as three questions rather than one with + filters: everything, one list's contents, and a search. Search wins when there is one — narrowing a + search to a list is a fourth thing the API does not offer, and pretending otherwise here would mean + filtering results in the browser and calling it the same feature. + */ + const path = + search.trim() ? `/karakeep/api/v1/bookmarks/search?q=${encodeURIComponent(search.trim())}` + : listId !== ALL ? `/karakeep/api/v1/lists/${encodeURIComponent(listId)}/bookmarks` + : '/karakeep/api/v1/bookmarks'; + + try { + const body = await clientRef.current.get(path); + setRows(body.bookmarks ?? []); + setError(null); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not read your bookmarks.'); + setRows(null); + } + }, []); + + // Debounced, because this types straight into a request: long enough that a five-letter word is one + // query instead of five, short enough that the list moves while you type. + useEffect(() => { + const timer = setTimeout(() => void load(list, query), 250); + return () => clearTimeout(timer); + }, [list, query, load]); + + return ( +
+
+ setQuery(event.target.value)} + placeholder="Search titles, notes, tags and saved text…" + className="h-8 text-xs" + /> + {query.trim() && list !== ALL && ( + // Said rather than silently ignored: the result set is not what the sidebar implies. +

Searching everything, not just this list.

+ )} +
+ + {error && ( +

+ {error} + {error.includes('no Karakeep account') && ' — the owner maps accounts in Settings.'} +

+ )} + + {rows === null && !error ? ( + Loading… + ) : rows && rows.length === 0 ? ( + {query.trim() ? `Nothing matches “${query.trim()}”.` : 'Nothing saved here yet.'} + ) : ( +
    + {rows?.map((bookmark) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +}; + +const Row = ({ bookmark }: { bookmark: Bookmark }) => { + const content = bookmark.content; + const url = content?.type === 'link' ? content.url : null; + const title = + bookmark.title || + (content?.type === 'link' ? content.title : null) || + (content?.type === 'asset' ? content.fileName : null) || + (content?.type === 'text' ? content.text?.slice(0, 80) : null) || + url || + 'Untitled'; + + const Icon = content?.type === 'text' ? StickyNote : content?.type === 'asset' ? ImageIcon : FileText; + + return ( +
+ +
+ {url ? ( + + {title} + + + ) : ( + // A note or an asset has nowhere to go, so it is not dressed as a link. + {title} + )} + {url &&

{url}

} + + {bookmark.tags && bookmark.tags.length > 0 && ( +

+ {bookmark.tags.map((tag) => ( + + {tag.name} + + ))} +

+ )} +
+
+ ); +}; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); diff --git a/web/ListsPanel.tsx b/web/ListsPanel.tsx new file mode 100644 index 0000000..807d0f0 --- /dev/null +++ b/web/ListsPanel.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { useClient } from 'hooks/useClient'; +import { Folder, Settings2, Layers } from 'lucide-react'; +import { SettingsForm } from './SettingsForm'; + +/** + * The lists, and a way back to the settings. + * + * Selection is `?list=` in the URL rather than a channel, so which list you are looking + * at survives a reload and can be linked to — `docs/navigation-audit.md`. The links panel reads the same + * parameter rather than being handed the id. + * + * The settings live behind the gear rather than on a screen of their own. They were a screen of their + * own for an afternoon and it was the wrong shape: nothing else was built, so opening the plugin put you + * on a configuration form with no way forward, which reads as broken rather than unfinished. + */ +/** Karakeep list ids are strings, and lists nest through `parentId`. */ +type List = { id: string; name: string; icon?: string | null; parentId?: string | null }; + +/** `?list=` for "everything", which is a real choice and not the absence of one. */ +export const ALL = 'all'; + +export const ListsPanel = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [rows, setRows] = useState(null); + const [error, setError] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [params, setParams] = useSearchParams(); + const selected = params.get('list') ?? ALL; + + const load = useCallback(async () => { + try { + const body = await clientRef.current.get<{ lists: List[] }>('/karakeep/api/v1/lists'); + setRows(body.lists ?? []); + setError(null); + } catch (failure) { + // The message matters here. "No account is mapped to you" is something the owner can fix and is + // the state every user starts in, so it must not read as an outage. + setError(failure instanceof Error ? failure.message : 'Could not read your lists.'); + setRows(null); + setShowSettings(true); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const select = (id: string) => + setParams( + (p) => { + p.set('list', id); + return p; + }, + { replace: true }, + ); + + return ( +
+
+ Lists + +
+ + {error &&

{error}

} + +
    +
  • + +
  • + + {rows?.map((row) => ( +
  • + +
  • + ))} +
+ + {showSettings && } +
+ ); +}; diff --git a/web/SettingsForm.tsx b/web/SettingsForm.tsx new file mode 100644 index 0000000..356ee33 --- /dev/null +++ b/web/SettingsForm.tsx @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +/** + * Where this install's Karakeep is, and whether it answers. + * + * Was the whole plugin for an afternoon, which was the mistake: a settings screen with nothing behind it + * is a screen you cannot leave. It is a disclosure inside the lists panel now — reachable when + * something is wrong, and out of the way when it is not. + * + * ── Two addresses, and they are not the same one ── + * + * The field below is the address the SIDECAR dials, which on a machine running both Officer and + * Karakeep is loopback. It is NOT the address a browser or the extension is given — that is + * `NEXTAUTH_URL` inside Karakeep's own container, and it has to be reachable from wherever the client + * is. Measured on this install: the name Karakeep advertises resolves to 127.0.1.1 on the host it runs + * on, where nothing is bound, so a sidecar told to use it dials a name that answers from everywhere + * except the one place that matters. + */ +type Config = { url: string | null; enabled: boolean; updatedAt: string | null }; +type Health = { ok: boolean; upstream?: number; reason?: string }; + +export const SettingsForm = () => { + const client = useClient(); + // `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list. + const clientRef = useRef(client); + clientRef.current = client; + + const [config, setConfig] = useState(null); + const [url, setUrl] = useState(''); + const [adminApiKey, setAdminToken] = useState(''); + const [health, setHealth] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + const next = await clientRef.current.get('/karakeep/config'); + setConfig(next); + setUrl(next.url ?? ''); + setError(null); + } catch { + setError('Could not read the configuration.'); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const check = useCallback(async () => { + setHealth(null); + try { + setHealth(await clientRef.current.get('/karakeep/config/health')); + } catch { + setHealth({ ok: false, reason: 'the check itself failed' }); + } + }, []); + + const save = async () => { + setBusy(true); + try { + // The token is sent only when one was typed: leaving a write-only field alone means "unchanged", + // not "clear it". + await clientRef.current.put('/karakeep/config', { + url: url.trim() || null, + ...(adminApiKey.trim() ? { adminApiKey: adminApiKey.trim() } : {}), + }); + setAdminToken(''); + setError(null); + await load(); + await check(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not save that.'); + } finally { + setBusy(false); + } + }; + + return ( +
+ {error &&

{error}

} + +
+ + setUrl(event.target.value)} + placeholder="http://127.0.0.1:3000" + className="h-8 text-xs" + /> +
+ +
+ + setAdminToken(event.target.value)} + placeholder={config?.url ? 'unchanged' : 'Karakeep → Settings → Access Tokens'} + className="h-8 text-xs" + /> +
+ +
+ + +
+ + {health && ( +

+ {health.ok ? ( + Answering{health.upstream ? ` (${health.upstream})` : ''}. + ) : ( + /* + Three distinct reasons on purpose — unconfigured, unreachable, sidecar not running are three + different next actions, and one word for all of them sends somebody to check the wrong thing. + */ + Not answering — {health.reason ?? 'no reason given'}. + )} +

+ )} +
+ ); +}; diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..3a7148e --- /dev/null +++ b/web/layout.ts @@ -0,0 +1,13 @@ +import type { LayoutNode } from 'officerdev'; + +// The bookmarks are the screen; the lists are how you narrow them. 24/76 because a list name is short +// and a saved page needs room for its title, its address and its tags without wrapping. +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'karakeep-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'karakeep-lists', appType: 'karakeep-lists' }, size: 24 }, + { node: { type: 'panel', id: 'karakeep-bookmarks', appType: 'karakeep-bookmarks' }, size: 76 }, + ], +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..f424c87 --- /dev/null +++ b/web/panels.ts @@ -0,0 +1,15 @@ +import type { AppRegistryMeta } from 'officerdev'; +import { Bookmark, ListTree } from 'lucide-react'; +import { ListsPanel } from './ListsPanel'; +import { BookmarksPanel } from './BookmarksPanel'; + +// Two panels: what you have saved, and how it is filed. The settings are a disclosure inside the lists +// panel rather than a screen of their own — a plugin whose only screen is its own configuration is one +// you cannot leave, which is how the first version of the sister plugin shipped and what it taught. +// +// `availableOnPanel: false` keeps them out of the generic picker: they belong to the /karakeep screen +// rather than being things to drop into an arbitrary dashboard. +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'karakeep-lists', name: 'Lists', icon: ListTree, component: ListsPanel, availableOnPanel: false }, + { key: 'karakeep-bookmarks', name: 'Bookmarks', icon: Bookmark, component: BookmarksPanel, availableOnPanel: false }, +];