diff --git a/.changeset/link-previews-on-by-default.md b/.changeset/link-previews-on-by-default.md new file mode 100644 index 00000000..0131192c --- /dev/null +++ b/.changeset/link-previews-on-by-default.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": minor +--- + +External link hover previews are now on by default. Previously they shipped off by default (opt-in). Hovering an external link in the editor now shows a preview card — site name, page title, description, favicon — fetched by your local server, which sends that link's URL to the destination site (one request per previewed link). Turn it off per machine in Settings → Link previews, which sets `linkPreviews.enabled: false` in project-local config. Unchanged: the SSRF guard still refuses to fetch private or internal hosts, no cookies or credentials are attached, results are cached locally, and any fetch failure falls back silently to the plain URL pill. Internal document-to-document previews were already always on and read entirely from the local index with no network request. Note: external previews are not yet available in the packaged desktop app; this default applies to the browser and `ok ui` surfaces. diff --git a/docs/content/reference/configuration.mdx b/docs/content/reference/configuration.mdx index 11362c4f..81eead25 100644 --- a/docs/content/reference/configuration.mdx +++ b/docs/content/reference/configuration.mdx @@ -51,7 +51,7 @@ A key set in a file more specific than its scope (a user-scope key in `.ok/confi | `search.semantic.model` | string | `"text-embedding-3-small"` | project-local | Embeddings model id. Must be served by the provider at `baseUrl`. Changing it re-embeds the corpus. | | `search.semantic.dimensions` | number | (native) | project-local | Optional output vector size. Omit for the model's native size (1536 for `text-embedding-3-small`). Set a smaller value to shrink the on-disk cache, trading a little quality. | | `search.semantic.similarityFloor` | number | (unset) | project-local | Optional hard cutoff (0–1): drops semantic matches whose cosine similarity is below it. Retrieval is rank-based, so most setups leave it unset; set it only for a provider/model whose cosine scale you know. | -| `linkPreviews.enabled` | boolean | `false` | project-local | Show a rich preview card (site name, page title, description, favicon) when you hover an external link in the editor. Default off. **When on, hovering an external link sends that link's URL to the destination site** to fetch its preview metadata — outbound egress, one request per previewed link. Previews of links to other documents in the project are read from the local index with no network request and are always on. See [Link previews](#link-previews) below. | +| `linkPreviews.enabled` | boolean | `true` | project-local | Show a rich preview card (site name, page title, description, favicon) when you hover an external link in the editor. Default on (set to `false` to opt out). **When on, hovering an external link sends that link's URL to the destination site** to fetch its preview metadata — outbound egress, one request per previewed link. Previews of links to other documents in the project are read from the local index with no network request and are always on. See [Link previews](#link-previews) below. | If `.ok/config.yml` contains a key that has been removed from the schema, config load fails with an error naming the replacement — a stale key is never a silent no-op. `ok config migrate` (`--dry-run` to preview; `--scope project|user|both`) strips removed keys from your config files. @@ -111,13 +111,17 @@ The first semantic search kicks off a background embed of the corpus (cents for Hovering a link in the editor shows a preview card. For **internal links** (other documents in your project) the card — title, folder, tags, last-edited time, backlink count, and a short excerpt — is built entirely from the local index and file contents: always on, no configuration, and nothing leaves the machine. -For **external links**, an opt-in card shows the destination's site name, page title, description, and favicon. It is **off by default**. +For **external links**, a card shows the destination's site name, page title, description, and favicon. It is **on by default**; turn it off per machine in Settings. + + + **Desktop app:** external link previews are not yet available in the packaged desktop app (its `file://` renderer is rejected by the preview route's anti-proxy gate), so this default applies to the browser and `ok ui` surfaces. Internal document-to-document previews work everywhere. + **URL egress.** When `linkPreviews.enabled` is on, hovering an external link sends that link's URL to the destination site — one metadata request per previewed link, from your machine, with no cookies or credentials attached. Responses are size- and time-capped, results are cached locally under `.ok/local/`, and requests that resolve to private or internal addresses are refused. Nothing else is sent: internal-link cards never touch the network. -To turn it on (per machine): the **Settings → This project → Link previews** toggle, which sets `linkPreviews.enabled: true` in project-local config (`/.ok/local/config.yml`), picked up live by a running server. The opt-in is enforced by the local server, so with it off no external request is made. A preview that can't be fetched (offline, timeout, the site blocks it) quietly falls back to the plain URL pill. External previews are not currently available in the packaged desktop app — the toggle applies to the browser and `ok ui` surfaces. +To turn it off (per machine): the **Settings → This project → Link previews** toggle, which sets `linkPreviews.enabled: false` in project-local config (`/.ok/local/config.yml`), picked up live by a running server. The setting is enforced by the local server, so with it off no external request is made. A preview that can't be fetched (offline, timeout, the site blocks it) quietly falls back to the plain URL pill. ## Verifying changes diff --git a/packages/app/src/components/settings/LinkPreviewsSection.dom.test.tsx b/packages/app/src/components/settings/LinkPreviewsSection.dom.test.tsx index a68041ff..1698be0c 100644 --- a/packages/app/src/components/settings/LinkPreviewsSection.dom.test.tsx +++ b/packages/app/src/components/settings/LinkPreviewsSection.dom.test.tsx @@ -1,6 +1,6 @@ /** * Tier-3 RTL mount tests for Settings → Link previews (external link-preview - * egress opt-in). + * egress control; on by default, this section is the per-machine opt-out). * * Behavior is driven through the project-local ConfigContext (mocked binding + * preference) and asserted on user-visible output: the toggle state, the egress diff --git a/packages/app/src/components/settings/LinkPreviewsSection.tsx b/packages/app/src/components/settings/LinkPreviewsSection.tsx index 681ca09d..5ad63014 100644 --- a/packages/app/src/components/settings/LinkPreviewsSection.tsx +++ b/packages/app/src/components/settings/LinkPreviewsSection.tsx @@ -1,9 +1,9 @@ /** - * Settings → Link previews — opt-in rich preview cards for external links in - * the editor hover panel. Per-machine (project-local scope) because enabling it - * sends the hovered URL to the destination site to fetch its metadata; each - * teammate opts in deliberately on their own machine rather than inheriting one - * collaborator's egress choice through git. + * Settings → Link previews — rich preview cards for external links in the + * editor hover panel. On by default; this section is the per-machine opt-out. + * Per-machine (project-local scope) because it controls outbound egress: each + * machine keeps its own choice rather than inheriting one collaborator's egress + * setting through git. * * The toggle reads the synchronous project-local CRDT preference (the same * pattern as the Search and Sync sections — never the server's resolved state, diff --git a/packages/app/src/components/settings/SettingsDialogBody.tsx b/packages/app/src/components/settings/SettingsDialogBody.tsx index 66f6fdec..75f7c47b 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.tsx @@ -273,11 +273,11 @@ export function SettingsDialogBody({ return ; } if (activeId === 'link-previews') { - // Project-local external-link-preview egress opt-in. Reads its own - // project-local binding from ConfigContext, same as SearchSection. The - // nav item is hidden on the packaged file:// renderer, whose Origin: - // null requests the preview route's anti-proxy gate rejects (see the - // gating in SettingsDialogShell). + // Project-local external-link-preview egress control (on by default; this + // section is the per-machine opt-out). Reads its own project-local binding + // from ConfigContext, same as SearchSection. The nav item is hidden on the + // packaged file:// renderer, whose Origin: null requests the preview route's + // anti-proxy gate rejects (see the gating in SettingsDialogShell). return ; } if (activeId === 'terminal') { diff --git a/packages/core/src/config/read-config-safely.test.ts b/packages/core/src/config/read-config-safely.test.ts index ea15aa64..3e60805c 100644 --- a/packages/core/src/config/read-config-safely.test.ts +++ b/packages/core/src/config/read-config-safely.test.ts @@ -27,6 +27,10 @@ describe('readConfigSafely', () => { expect(result.source).toBeUndefined(); expect(result.value.content.dir).toBe('.'); expect(result.value.autoSync.enabled).toBeNull(); + // Pins the on-by-default egress posture at the read layer the server's + // readLinkPreviewsEnabled() relies on: a genuinely-absent project-local + // config resolves to external link previews enabled. + expect(result.value.linkPreviews.enabled).toBe(true); } }); diff --git a/packages/core/src/config/schema.test.ts b/packages/core/src/config/schema.test.ts index f69a421c..bcf51b33 100644 --- a/packages/core/src/config/schema.test.ts +++ b/packages/core/src/config/schema.test.ts @@ -170,13 +170,19 @@ describe('appearance.sidebar view toggles', () => { }); }); -describe('linkPreviews.enabled (external link-hover preview egress opt-in)', () => { - test('defaults to disabled when the block is absent', () => { - expect(ConfigSchema.parse({}).linkPreviews).toEqual({ enabled: false }); +describe('linkPreviews.enabled (external link-hover preview egress default)', () => { + test('defaults to enabled when the block is absent', () => { + expect(ConfigSchema.parse({}).linkPreviews).toEqual({ enabled: true }); }); - test('defaults enabled to false when linkPreviews is present but enabled is absent', () => { - expect(ConfigSchema.parse({ linkPreviews: {} }).linkPreviews.enabled).toBe(false); + test('defaults enabled to true when linkPreviews is present but enabled is absent', () => { + expect(ConfigSchema.parse({ linkPreviews: {} }).linkPreviews.enabled).toBe(true); + }); + + test('accepts an explicit opt-out', () => { + expect(ConfigSchema.parse({ linkPreviews: { enabled: false } }).linkPreviews.enabled).toBe( + false, + ); }); test('accepts an explicit opt-in', () => { diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 6ca2a6a1..ca830d88 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -674,11 +674,17 @@ export const ConfigSchema = z.looseObject({ }) .default({ markdownlint: { enabled: false } }), // PROJECT-LOCAL scope: external link-hover previews send the hovered URL to - // the destination site to fetch its metadata (egress), so — like semantic - // search — each teammate opts in on their own machine rather than inheriting - // one collaborator's egress choice through git. Default OFF; the feature - // ships dark. Internal (document-to-document) link previews are read entirely - // from the local index with no network request and are NOT gated by this key. + // the destination site to fetch its metadata (egress). Read per-machine from + // the project-local layer, never a committed/shared config, so one clone's + // choice never sets another's egress and a Settings toggle applies to the next + // hover without a restart. Default ON: an absent key resolves to enabled here, + // and a user turns external previews off with an explicit `enabled: false`. + // This defaults ON even though its sibling egress knob `search.semantic.enabled` + // defaults OFF: semantic search streams corpus content to a third-party + // embeddings provider and needs an API key, whereas a preview sends only a URL + // to the site the link already points at, so on-by-default is the right posture. + // Internal (document-to-document) link previews are read entirely from the + // local index with no network request and are NOT gated by this key. linkPreviews: z .looseObject({ enabled: z @@ -688,11 +694,11 @@ export const ConfigSchema = z.looseObject({ agentSettable: false, defaultScope: 'project-local', description: - "Show a rich preview card (site name, page title, description, favicon) when you hover an external link in the editor. When ON, hovering an external link sends that link's URL to the destination site to fetch its preview metadata — outbound egress, one request per previewed link. Default OFF. Per-machine (project-local) — not shared with collaborators. Previews of links to other documents in this project are read from the local index with no network request and are always on.", + "Show a rich preview card (site name, page title, description, favicon) when you hover an external link in the editor. When ON, hovering an external link sends that link's URL to the destination site to fetch its preview metadata — outbound egress, one request per previewed link. Default ON; set to false to turn external previews off. Per-machine (project-local) — not shared with collaborators. Previews of links to other documents in this project are read from the local index with no network request and are always on.", }) - .default(false), + .default(true), }) - .default({ enabled: false }), + .default({ enabled: true }), }); export type Config = z.infer; diff --git a/packages/server/src/server-factory.ts b/packages/server/src/server-factory.ts index e19c7a50..df4af47b 100644 --- a/packages/server/src/server-factory.ts +++ b/packages/server/src/server-factory.ts @@ -633,18 +633,26 @@ export function createServer(options: ServerOptions): ServerInstance { } // Same project-local, fresh-read contract as `readSemanticSearchConfig`, for - // the link-preview egress opt-in: `linkPreviews.enabled` must never be honored - // from a committed/shared config layer (one collaborator must not switch on - // egress for everyone who clones), and a Settings toggle must apply to the - // next hover without a restart. An absent or invalid config reads as disabled - // (`readConfigSafely` degrades to schema defaults), keeping the route - // fail-closed. + // the link-preview egress setting: `linkPreviews.enabled` is read only from + // the project-local layer, never a committed/shared config (one clone's choice + // must not set another's egress), and a Settings toggle must apply to the next + // hover without a restart. External previews default ON, so a genuinely-absent + // config resolves to enabled. A DEGRADED read fails closed instead of + // inheriting the on default (see the `local.valid` guard below), so an + // explicit `linkPreviews.enabled: false` opt-out is never silently reverted + // by unrelated config corruption. function readLinkPreviewsEnabled(): boolean { const local = readConfigSafely({ absPath: resolveConfigPath('project-local', projectDir, configHomedirOverride), sideline: false, warn: (message) => log.warn({ message }, '[config] could not read project-local config'), }); + // Fail closed on a degraded read (unreadable / invalid YAML / schema-invalid + // / removed-key): only a genuinely-absent config resolves to the on-by-default + // schema value. Without this, an explicit `enabled: false` opt-out would + // silently revert to egress-ON the moment the project-local file became + // unparseable (concurrent writer, crash mid-write, a stale key elsewhere). + if (!local.valid) return false; return local.value.linkPreviews?.enabled === true; }