okf-gem · docs
Guides

Mount the graph in Rails

Your team's knowledge, served inside your app, behind your auth.

Why mount it

okf server is great on a laptop, but a team usually wants the knowledge graph where the team already is: inside the internal Rails app, behind the same sign-in as everything else. The server was built for exactly that. The page under the UI is a plain Rack app, and its endpoints are mount-relative, so it works identically at / or under any prefix.

Add the gem

# Gemfile
gem "okf"

require "okf" loads only the library: the model, the analyzers, and the on-disk handles. The graph server is a separate, on-demand require, require "okf/server/app", which is exactly how the gem's own CLI loads it. Your app's boot never pays for Rack machinery it does not serve. The architecture page explains why the footprint stays this small.

Mount it in routes

# config/routes.rb
require "okf/server/app"   # the server loads on demand, like the CLI does

Rails.application.routes.draw do
  knowledge = OKF::Server::App.new(
    OKF::Bundle::Folder.load(Rails.root.join(".okf").to_s)
  )

  mount knowledge => "/knowledge"
end

That is the whole integration. Visit /knowledge and you get the same interactive graph as the public demo: nodes by type, the inspector with rendered Markdown, and the catalog, files, tags, and stats views. The files view is a nested tree of the bundle on disk, with each index.md and log.md sitting as a row at the top of the folder it documents, and an "Indexes only" toggle when those are all you want to see.

Many bundles: mount a hub

OKF::Server::App serves one bundle. To serve several behind one prefix, with an in-page switcher that jumps between them, mount OKF::Server::Hub instead. You hand it an ordered list of OKF::Server::Hub::Bundle structs (slug, folder, title); the first is the one the prefix root opens.

# config/routes.rb
require "okf/server/hub"

Rails.application.routes.draw do
  bundles = [
    OKF::Server::Hub::Bundle.new(
      "handbook",
      OKF::Bundle::Folder.load(Rails.root.join(".okf").to_s),
      "Handbook"
    ),
    # ...more bundles...
  ]

  mount OKF::Server::Hub.new(bundles) => "/knowledge"
end

Each bundle is served at /knowledge/b/<slug>/, and /knowledge redirects to the default. A plain Hub.new(bundles) is read-only: the registry-editing routes (POST /registry/*) answer only when you pass both an explicit writable: true and a registry:, and neither the hub nor the app ever writes to your bundle's Markdown either way. Mounting it exposes reading only, which is what you want inside someone else's app.

Preserve the trailing slash (the hub's one mount contract)

The hub serves each graph at /knowledge/b/<slug>/, with the trailing slash, and 301-redirects the slashless /knowledge/b/<slug> onto it. That slash is load-bearing, not cosmetic: the page's fetch endpoints are relative (../../search, ../<sibling>/), so they resolve correctly only when the document itself lives at .../b/<slug>/.

Here is the trap. A normalizing host router, Rails' mount included, strips the trailing slash from the mounted app's PATH_INFO before the hub ever sees it. So a browser request for /knowledge/b/handbook/ reaches the hub looking slashless, the hub 301s to add the slash, the router strips it again, and the two spin into an infinite redirect loop (ERR_TOO_MANY_REDIRECTS).

The hub cannot fix this from standard Rack: it has no way to tell "the visitor really typed no slash" (301 is correct) from "the router stripped the slash the visitor sent" (301 is wrong). Only the host still holds that signal, so the host has to restore the slash before delegating. In Rails, wrap the hub in a small middleware:

class OKFMountAdapter
  def initialize(app)
    @app = app
  end

  def call(env)
    request = ActionDispatch::Request.new(env)
    original = request.original_fullpath.to_s.split("?", 2).first
    path_info = env["PATH_INFO"].to_s
    # Append only when the browser's real path ended in "/", so leaf
    # endpoints like /node and /search are left untouched.
    if original.end_with?("/") && !path_info.end_with?("/")
      env["PATH_INFO"] = "#{path_info}/"
    end
    @app.call(env)
  end

  # Keep `rails routes` and the dev error page readable. A hub's default
  # inspect walks every loaded bundle, concept, and body, which is
  # megabytes of output; a terse one keeps the route table usable.
  def inspect = "#<OKFMountAdapter app=OKF::Server::Hub>"
end

mount OKFMountAdapter.new(OKF::Server::Hub.new(bundles)) => "/knowledge"

That wrapper is also the natural place to enforce auth: return a 302 to your sign-in route, or a 404, before you call @app.call(env), since a mounted Rack app bypasses your controllers' before_actions. The auth patterns below apply to the hub as much as to the single-bundle app.

Put your auth in front

The graph inherits whatever stands in front of it, which is the point of mounting instead of running a second service. With Devise:

authenticate :user do
  mount knowledge => "/knowledge"
end

Or, framework-free, wrap it in basic auth:

protected_graph = Rack::Builder.new do
  use Rack::Auth::Basic, "Knowledge" do |user, pass|
    ActiveSupport::SecurityUtils.secure_compare(pass, ENV.fetch("KNOWLEDGE_PASS"))
  end
  run knowledge
end

mount protected_graph => "/knowledge"

Anything Rack understands works: Devise, Warden scopes, IP allowlists, your SSO middleware.

No Rails? Plain Rack works too

# config.ru
require "okf"
require "okf/server/app"

run OKF::Server::App.new(OKF::Bundle::Folder.load(".okf"))

rackup serves it with whatever server you already run (Puma, Falcon). The built-in WEBrick runner behind okf server is just a convenience wrapper around this same app.

What updates live, and what does not

Concept bodies are fetched from disk on every click, and the Files view re-reads log.md every time it is opened, so editing a concept or appending a log entry shows up next time with no restart. The bundle's structure (which files exist, how they link, the authored index maps) is read when Folder.load runs, so adding or removing concepts needs an app restart or a fresh folder load, same as any boot-time configuration.

One boundary worth respecting in a mounted setup: the page renders bundle content through DOMPurify and escapes everything it inlines, but it still loads its viewer libraries from a CDN and renders whatever links the bundle carries. Serve bundles you trust; the graph server page has the full trust write-up.

esc
navigate open