Skip to content

A write API for Writebook - #470

Draft
dhh wants to merge 16 commits into
mainfrom
api
Draft

A write API for Writebook#470
dhh wants to merge 16 commits into
mainfrom
api

Conversation

@dhh

@dhh dhh commented Aug 13, 2026

Copy link
Copy Markdown
Member

Lets a book be maintained in a git repo and mirrored into Writebook by a script — the write half of what the `.md` exports started. Driving use case: the Omarchy manual syncing from `basecamp/omarchy` to learn.omacom.io on push. Full design and decisions in `plans/api.md`.

How it works

  • Auth: every user gets a resettable `bearer_key` (`has_secure_token`), presented as `Authorization: Bearer `. It resolves to the real `User` without a Session row, so `Access` rows and `book.editable?` keep working untouched. Keys only authenticate on controllers that opt in via `allow_bearer_key_access` — everywhere else the request stays anonymous, so a leaked key can drive the API but not account/user admin. This also brings the inherited-but-dead `bot_key` CSRF exemption to life as `bearer_key`.
  • Wire format: the per-leaf `.md` document (front matter + verbatim body), now rendered and parsed by one class, `Leaf::Document`. `PUT` back exactly what you `GET` and nothing records. Sections speak plain JSON; the manifest (`GET /books/:id/leaves.json`) is the only JSON read.
  • Upsert: `external_id` on leaves, unique per book. `POST` with one finds-or-creates — and restores a trashed match, so a bad sync heals on the next good push.
  • Uploads: `POST /books/:book_id/pages/:id/uploads.json` resolves the page from the path instead of the editor-minted signed GlobalID. `fileUrl` is now absolute (reversing the earlier relative-URL stance) so bodies mirrored into git render on GitHub too.

Bugs fixed on the way

  • `will_change_leafable?` couldn't see markdown bodies (association, not column), so every edit >10 min after the last recorded a revision even for identical content — fatal for a sync that re-sends all pages per push.
  • Front-matter titles weren't escaped: a `"` in a title emitted invalid YAML, and ERB HTML-escaped `&` inside markdown.
  • `request.raw_post` is ASCII-8BIT; the first real manual page with an em dash blew up at the SQLite layer.

Validation

Mirrored the live Omarchy manual (45 pages + 4 sections from learn.omacom.io) into a local instance with a sync script driving only the public API: initial sync, idempotent re-sync (zero new Edits), reorders via filename-prefix renumbering, git-wins overwrite of web edits, trash/restore, and uploads.

🤖 Generated with Claude Code

dhh and others added 16 commits August 13, 2026 14:27
will_change_leafable? compared leafable.attributes[key], but a Page's
body is a has_markdown association, not a column, so attributes["body"]
was always nil and any submitted body counted as a change. Every edit
more than 10 minutes after the last recorded a revision even when the
content was identical, duplicating the page and its markdown row.

Compare the markdown content for markdown attributes so no-op edits stay
no-ops. Groundwork for the write API, where a sync client re-sends every
page on every push and relies on unchanged pages recording nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
title: "<%= @leaf.title %>" emitted invalid YAML whenever a title
contained a double quote, and ERB's HTML escaping turned & into &amp;
inside what claims to be markdown. Emit the title as a JSON string,
which is valid YAML and needs no further escaping. Plain JSON.generate
rather than to_json to avoid ActiveSupport's \u0026-style HTML entity
escapes, which are correct but needlessly unreadable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
has_secure_token, same as Session. The key authenticates non-browser
clients in the next commit; regenerate_bearer_key handles resets. The
migration backfills existing users, since has_secure_token only
generates on create.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Authorization: Bearer <key> resolves to a real, active User and sets
Current.user without a Session row, so Access rows and book.editable?
keep working untouched. This brings the previously dead bot_key CSRF
exemption to life under its new bearer_key name.

Keys only authenticate on controllers that opt in with
allow_bearer_key_access — everywhere else the request stays anonymous,
so a leaked key can drive the API surface but not the account or user
admin. Opting in the authentication attempt rather than denying after
the fact avoids a callback-ordering hole on allow_unauthenticated_access
controllers, where a default-deny check would run before the bearer
authentication it is supposed to veto.

Requests that present a key (or want a non-HTML format) get 401 instead
of the login redirect. books#show opts in first, so a key can read an
unpublished book it has access to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stable upsert key for sync clients: a leaf keeps its identity across
pushes without the client maintaining its own name-to-id map. Unique per
book; nullable, since leaves made in the web UI have none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One serializer/parser both directions share: YAML front matter, then
the body, verbatim. The leaf .md export now renders through it, and the
write API will parse requests with it, so PUT-ing back what you GET is
byte-exact by construction.

The parser takes the first closing delimiter and exactly one blank line
after it, so bodies containing --- lines and leading/trailing
whitespace round-trip untouched. Titles are emitted as JSON strings
(valid YAML); parsing is YAML, so hand-authored files with unquoted
values work too. url is output-only and unknown keys are ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /books/:book_id/leaves.json lists the active leaves in reading
order: id, type, title, slug, position, external_id, url. This is what
a mirror diffs against to decide what to create, update, reorder, and
trash. position is the 0-based index, matching what move_to_position
takes and what the arrangement UI submits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /books/:book_id/pages.md creates from a Leaf::Document; carrying
an external_id makes it an upsert that updates the existing leaf — and
restores it if it was trashed, so a bad sync heals on the next good
push. PUT /books/:book_id/pages/:id.md updates through Leaf#edit, so
revision coalescing and no-op detection apply; PUT-ing back exactly
what you GET records nothing. DELETE trashes, as the web UI does.
position (0-based, same as the arrangement UI) applies through
move_to_position.

The browser flows are untouched: document handling only engages for
md/json requests, which also skip the being-edited broadcast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The leafables controller already carries the create/upsert/update
machinery; these prove it for sections, which speak JSON instead of the
.md document format since their body is a bare string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /books/:book_id/pages/:id/uploads.json resolves the page from the
path and authorizes with the book's editability, then reuses the same
attach-and-render as the editor's upload endpoint — which no script
could use, since the signed GlobalID is only minted into the page
editor.

fileUrl is now absolute, reversing the earlier relative-URL stance:
bodies mirrored into a git repo need URLs that render on GitHub and in
local editors, and byte-identical bodies on both sides is what makes a
re-sync a no-op. The trade, made deliberately: a hostname change means
a one-time rewrite of stored bodies. main_app-qualified in the
template because the shared view also renders inside the isolated
ActionText namespace, where bare url helpers resolve against the
engine's routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
request.raw_post arrives ASCII-8BIT, so a body with any multibyte
character blew up at the database layer once a real manual page came
through. The wire format is UTF-8; declare it at the parse boundary
and reject bodies that aren't valid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
on_load(:active_storage_attachment) only fired once something else
loaded ActiveStorage::Attachment, so a process whose first
storage-touching request was an upload raised NoMethodError on
Markdown#uploads. Surfaced by an API-only sync process; the editor path
was masked because page renders load storage classes first.
has_many_attached doesn't exist until after the initializers run, so
the include goes in to_prepare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Puts the bearer key on the profile edit page, next to the session
transfer link it resembles: a readonly field, a copy button, and a
reset. Only on your own settings page — the profile show page is
visible to everyone in the account, so the key never renders there.

Reset regenerates in place, matching how join codes reset. Scripts
holding the old key start getting 401s immediately, hence the confirm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant