Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/link-previews-on-by-default.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions docs/content/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

<Callout type="info">
**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.
</Callout>

<Callout type="warn">
**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.
</Callout>

To turn it on (per machine): the **Settings → This project → Link previews** toggle, which sets `linkPreviews.enabled: true` in project-local config (`<project>/.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 (`<project>/.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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 5 additions & 5 deletions packages/app/src/components/settings/LinkPreviewsSection.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
10 changes: 5 additions & 5 deletions packages/app/src/components/settings/SettingsDialogBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,11 +273,11 @@ export function SettingsDialogBody({
return <PluginSection key={activeId} />;
}
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 <LinkPreviewsSection />;
}
if (activeId === 'terminal') {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/config/read-config-safely.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});

Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 14 additions & 8 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<typeof ConfigSchema>;
Expand Down
20 changes: 14 additions & 6 deletions packages/server/src/server-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down