From 858cf3f46770857b58db1c7cedf4e108511fc880 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:27:05 +0200 Subject: [PATCH 01/16] Plan for API --- plans/api.md | 240 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 plans/api.md diff --git a/plans/api.md b/plans/api.md new file mode 100644 index 00000000..8b0ebe7e --- /dev/null +++ b/plans/api.md @@ -0,0 +1,240 @@ +# A write API for Writebook + +## Goal + +Let a book be maintained in a git repo and mirrored into Writebook by a script, +without a shell account on the box. Read access already exists; this plan adds +the write half and the authentication to use it. + +The driving use case is the Omarchy manual, whose authoritative source is moving +to `basecamp/omarchy` under `manual/` (45 pages, one file per leaf, ordered by +filename prefix). A push to that repo should update learn.omacom.io. + +## What already exists + +Most of the pieces are here and unexposed. Read the following before starting. + +**The read half is done.** `BooksController#show` and `LeafablesController#show` +both `respond_to { format.md }` under `allow_unauthenticated_access`, rendering +`app/views/books/show.md.erb` and `app/views/leafables/show.md.erb`. That is where +`https://learn.omacom.io/2/the-omarchy-manual.md` comes from. + +**CSRF exemption for keyed clients is already wired, and dead.** +`app/controllers/concerns/authentication.rb:9`: + +```ruby +protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? } +``` + +Nothing in this repo ever calls `set_authenticated_by(:bot_key)`, so the branch is +unreachable. Writebook inherited the guard from the shared ONCE `Authentication` +concern without the implementation. Campfire has the other half. + +**Correct upsert semantics already exist.** `Leaf::Editable#edit` (`app/models/leaf/editable.rb`) +is what the API must call: + +```ruby +MINIMUM_TIME_BETWEEN_VERSIONS = 10.minutes + +def record_new_edit?(leafable_params) + will_change_leafable?(leafable_params) && last_edit_old? +end +``` + +An unchanged page records no revision, and repeated edits inside 10 minutes +coalesce. A sync that re-sends all 45 pages on every push is therefore cheap and +does not shred the revision history — **provided it goes through `Leaf#edit` and +not `page.update!`**. This is the single easiest thing to get wrong. + +**Other relevant API:** `Book#press(leafable, leaf_params)` creates a leaf; +`Positionable#move_to_position(offset)` reorders; `Leaf#slug` is +`title.parameterize`; URLs are `/:book_id/:book_slug/:id/:slug`. + +## What's missing + +1. No way to authenticate a non-browser client. +2. No write endpoints. +3. No stable key to upsert against. +4. No upload path usable without a browser session. + +## Design + +### 1. Authentication: a personal bearer key + +No bot users, no role changes. Every user gets a resettable API key: + +```ruby +has_secure_token :bearer_key # Session already uses has_secure_token +``` + +Authenticate from the `Authorization: Bearer ` header (no path/param key — +those land in logs, `Referer`, and proxy traces): + +```ruby +def restore_authentication + if session = find_session_by_cookie + resume_session session + elsif user = authenticate_with_http_token { |token, _| User.active.find_by(bearer_key: token) } + Current.user = user + set_authenticated_by :bearer_key + end +end +``` + +`Current` has a `user` attribute settable independently of `session` +(`app/models/current.rb`), so no `Session` row is involved. Scoping the lookup to +`User.active` means deactivating a user kills their key, and +`user.regenerate_bearer_key` (free with `has_secure_token`) handles resets. + +Because the key resolves to a real `User`, **the existing `Access` rows and +`book.editable?` checks keep working untouched**, including in +`ActionText::Markdown::UploadsController`. Do not invent a parallel authorization +path. Per-book scoping falls out too: the Omarchy mirror runs as a dedicated +ordinary user (say, "Omarchy Sync") holding an editor `Access` row on just the +manual — no new mechanism. + +**Default-deny stays, reshaped.** A bearer key lives in CI secrets and scripts, so +it is far leakier than a session cookie; it must not be a full session equivalent. +Keep Campfire's shape but keyed to the header auth: + +```ruby +before_action :deny_bearer_keys # default deny +def deny_bearer_keys = head :forbidden if authenticated_by.bearer_key? +``` + +with `allow_bearer_key_access` to opt in the API controllers (and the new uploads +route) only. This makes the dead CSRF guard at +`app/controllers/concerns/authentication.rb:9` live — rename its `bot_key?` +inquiry to `bearer_key?` to match. + +Expose show/reset on the existing user profile (`Users::ProfilesController`); +until then, `regenerate_bearer_key` from the console is enough. Note that +`request_authentication` redirects to login — the bearer path must render +`head :unauthorized` for API requests instead of a 302. + +### 2. Representation: the leaf `.md` document round-trips; JSON only for the manifest + +Round-trip is scoped to leaves. The book-level `.md` stays a lossy, human-facing +export (`Book#markable` joins with `"\n\n"`, sections are indistinguishable from +prose) — the sync never reads it. + +The per-leaf `.md` (`app/views/leafables/show.md.erb`) is already the right +document: front matter (`title`, `url`) plus the raw body, and `has_markdown` +stores body as verbatim markdown source, so it round-trips byte-for-byte. Writes +accept the same format (`Content-Type: text/markdown`) instead of JSON-wrapping +markdown in strings. `PUT` back what you `GET`, edited. + +To make the round trip exact: + +- **Escape the title.** `title: "<%= @leaf.title %>"` emits invalid YAML when the + title contains `"`. Emit a JSON-encoded string (valid YAML) and parse it back. +- **Fix the parsing rule.** Front matter is required on write, starts at byte 0, + and ends at the first `\n---\n`; everything after is body, verbatim. Bodies + containing `---` lines survive. +- **`url:` is output-only.** Ignore it (and any unknown keys) on write. Accept + optional `position` and `external_id` keys. +- **No whitespace drift.** The serializer must not append newlines the parser + drops, or every sync run looks like a change. + +Sections don't need round-trip; they stay writable with a plain JSON body +(`title`, `body`, `theme`) — `Section#markable` is a bare string anyway. The one +JSON read endpoint is the manifest: `id`, `leafable_type`, `title`, `slug`, +`position`, `external_id` per leaf. + +### 3. Endpoints + +Nest under the existing `resources :books` in `config/routes.rb`, opted in with +`allow_bearer_key_access`: + +``` +GET /books/:book_id/leaves.json # ordered manifest: id, type, title, slug, position, external_id +POST /books/:book_id/pages.md # create from a leaf .md document (Book#press) +PUT /books/:book_id/pages/:id.md # update via Leaf#edit, same document format +DELETE /books/:book_id/pages/:id # Leaf#trashed! (soft, already how destroy works) +POST /books/:book_id/pages/:id/uploads.json +``` + +Reading a leaf already works: `GET /:book_id/:book_slug/:id/:slug.md`. The write +side parses `request.raw_post` per the rules in §2 — the `:md` mime type is +registered, but Rails won't parse a markdown request body into `params`. + +`sections` get the same routes with JSON bodies. `pictures` can wait — the +Omarchy manual uses inline markdown images, not Picture leaves. + +Reuse `SetBookLeaf`, which already gives `set_book` (`Book.accessable_or_published`), +`set_leaf` (`@book.leaves.active`), and `ensure_editable`. + +### 4. Upsert key + +Add `external_id` (string, nullable) to `leaves`, unique per book. Support upsert +by it, e.g. `PUT /books/:book_id/leaves/by_external_id/:external_id.json`, or a +`external_id` field on create that finds-or-creates. + +Without this the client must keep its own filename→leaf-ID map, which drifts the +first time someone edits in the web UI, and makes renames indistinguishable from +delete+create. The Omarchy sync would set `external_id` to the repo-relative path +(`manual/27-monitors.md`). + +Deletes should trash, not destroy — `Leaf` already has `status: %w[active trashed]` +and `Leaf::Editable#record_moved_to_trash` logs it. A bad sync must be recoverable. + +### 5. Uploads + +`ActionText::Markdown::UploadsController#create` currently requires a signed +GlobalID minted into the editor: + +```ruby +@record = GlobalID::Locator.locate_signed params[:record_gid], + only: Page, for: ActionText::Markdown::UPLOADS_SIGNED_ID_PURPOSE +``` + +There is no way for a script to obtain one. Add a bearer-key-authenticated route +that resolves the page from `:book_id`/`:id` instead, authorizes with the existing +`ensure_editable`, and otherwise reuses the same attach-and-render path. Return the +`/u/...` URL. + +Serving already works for this use case: `#show` is `allow_unauthenticated_access` +and sets `expires_in 1.year, public: true` for published books, so the URLs render +on GitHub and cache well. + +### 6. Positioning + +Accept `position` on create/update and apply via `move_to_position`. Don't write +`position_score` directly — `Positionable` owns the gap arithmetic and the +`REBALANCE_THRESHOLD` rebalance. + +## Migrations + +1. `leaves.external_id` (string, index unique on `[book_id, external_id]`). +2. `users.bearer_key` (string, unique index). `has_secure_token` only generates on + create, so backfill existing users in the migration + (`User.find_each(&:regenerate_bearer_key)`). + +## Testing + +Per `AGENTS.md`: prefer existing fixtures (`leaves(:welcome_page)`), use `_path` +helpers, and `assert_in_body` / `assert_not_in_body`. + +Cover at minimum: + +- A bearer key authenticates and CSRF is skipped; a bad, reset, or deactivated + user's key gets `:unauthorized` (not a redirect). +- `deny_bearer_keys` blocks a valid key on a non-API controller (regression guard + for the default-deny above). +- A key whose user has no `Access` to a private book gets `:forbidden`. +- Re-sending identical content records **no** new `Edit`. +- Two edits inside `MINIMUM_TIME_BETWEEN_VERSIONS` produce one revision. +- Upsert by `external_id` updates rather than duplicating. +- `GET` then `PUT` of an untouched leaf `.md` is a no-op, and a title containing + `"` survives the round trip. +- Upload returns a `/u/...` URL that `#show` then serves. + +## Open questions + +- **Should Sections be writable in v1?** The Omarchy manual needs them, since its + four part dividers are Sections and currently exist nowhere in git. + +## Out of scope + +Picture leaves, book create/destroy, user management, and reading via JSON beyond +the manifest needed to sync. From e7fb9d0508f2814d17eaede354ed77d5b3badb33 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:39:01 +0200 Subject: [PATCH 02/16] Updated plan --- plans/api.md | 68 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/plans/api.md b/plans/api.md index 8b0ebe7e..7a006adc 100644 --- a/plans/api.md +++ b/plans/api.md @@ -41,10 +41,17 @@ def record_new_edit?(leafable_params) end ``` -An unchanged page records no revision, and repeated edits inside 10 minutes -coalesce. A sync that re-sends all 45 pages on every push is therefore cheap and -does not shred the revision history — **provided it goes through `Leaf#edit` and -not `page.update!`**. This is the single easiest thing to get wrong. +Repeated edits inside 10 minutes coalesce. **But the no-change detection is +broken for Pages**: `will_change_leafable?` compares `leafable.attributes[key]`, +and a Page's body is a `has_markdown` association, not a column — so +`attributes["body"]` is always nil and any submitted body counts as a change. +Re-sending 45 unchanged pages on a push more than 10 minutes after the last one +would record 45 junk revisions, each duplicating the Page and its Markdown row +(`update_and_record_edit` dups the leafable). Fix it in the model — compare +`leafable.body.content.to_s` for markdown attributes — so the web UI benefits +too. Sections are unaffected (`body` is a real column). With that fixed, the +cheap-resync property holds — **provided the API goes through `Leaf#edit` and +not `page.update!`**. **Other relevant API:** `Book#press(leafable, leaf_params)` creates a leaf; `Positionable#move_to_position(offset)` reorders; `Leaf#slug` is @@ -136,10 +143,13 @@ To make the round trip exact: - **No whitespace drift.** The serializer must not append newlines the parser drops, or every sync run looks like a change. -Sections don't need round-trip; they stay writable with a plain JSON body -(`title`, `body`, `theme`) — `Section#markable` is a bare string anyway. The one -JSON read endpoint is the manifest: `id`, `leafable_type`, `title`, `slug`, -`position`, `external_id` per leaf. +Sections are writable in v1 — the Omarchy manual's four part dividers are +Sections and currently exist nowhere in git. They don't need round-trip; they +stay writable with a plain JSON body (`title`, `body`, `theme`) — +`Section#markable` is a bare string anyway. In the repo a section is a file like +any other leaf, marked `type: section` in its front matter; the sync script reads +that to pick the endpoint. The one JSON read endpoint is the manifest: `id`, +`leafable_type`, `title`, `slug`, `position`, `external_id` per leaf. ### 3. Endpoints @@ -166,17 +176,31 @@ Reuse `SetBookLeaf`, which already gives `set_book` (`Book.accessable_or_publish ### 4. Upsert key -Add `external_id` (string, nullable) to `leaves`, unique per book. Support upsert -by it, e.g. `PUT /books/:book_id/leaves/by_external_id/:external_id.json`, or a -`external_id` field on create that finds-or-creates. +Add `external_id` (string, nullable) to `leaves`, unique per book. Upsert rides +the front matter: a `POST` whose document carries `external_id:` finds-or-creates +by it. No dedicated `/by_external_id/` route — the key contains `.`, which would +fight the router's format parsing. Without this the client must keep its own filename→leaf-ID map, which drifts the first time someone edits in the web UI, and makes renames indistinguishable from -delete+create. The Omarchy sync would set `external_id` to the repo-relative path -(`manual/27-monitors.md`). +delete+create. The Omarchy sync sets it to the filename with the ordering prefix +stripped: `27-monitors.md` → `monitors.md`. The prefix carries position, the name +carries identity — so renumbering files to reorder or insert pages (the common +case) doesn't churn identity, leaf ids, or published URLs. A true rename still +reads as delete+create: rare, and acceptable. Two files sharing a stripped name +collide on the unique index, which fails the sync loudly — an authoring error, +correctly rejected. Deletes should trash, not destroy — `Leaf` already has `status: %w[active trashed]` and `Leaf::Editable#record_moved_to_trash` logs it. A bad sync must be recoverable. +A trashed leaf keeps its `external_id`, so upsert must match trashed leaves too +and restore them (back to `active`, then `Leaf#edit`) rather than collide with the +unique index — a bad sync that trashed pages heals itself on the next good push. + +**Git always wins.** The sync trashes leaves absent from the repo and overwrites +web-UI edits on the next push. Soft-trash and the revision history make both +recoverable. This makes web editing of a mirrored book advisory — that's the +simplicity trade, made deliberately. ### 5. Uploads @@ -197,6 +221,16 @@ Serving already works for this use case: `#show` is `allow_unauthenticated_acces and sets `expires_in 1.year, public: true` for published books, so the URLs render on GitHub and cache well. +**Uploads return absolute URLs, and the editor inserts them.** Today the editor +inserts the relative `/u/` path — `create.json.jbuilder` renders `fileUrl` +from `Attachment#slug_path` (`lib/rails_ext/active_storage_sluggable.rb:8`). +Switch that to the full URL (`action_text_markdown_upload_url`) so bodies carry +the same explicit URL the git source does. Images then render on GitHub and in +local editors, and the byte-for-byte round trip holds with no rewriting on either +side. The cost is baking the canonical host into stored bodies — a domain move +needs a one-time rewrite. Existing bodies hold relative paths; normalize the +manual's once during the initial export to git. + ### 6. Positioning Accept `position` on create/update and apply via `move_to_position`. Don't write @@ -224,16 +258,12 @@ Cover at minimum: - A key whose user has no `Access` to a private book gets `:forbidden`. - Re-sending identical content records **no** new `Edit`. - Two edits inside `MINIMUM_TIME_BETWEEN_VERSIONS` produce one revision. -- Upsert by `external_id` updates rather than duplicating. +- Upsert by `external_id` updates rather than duplicating, and restores a + trashed match instead of colliding with the unique index. - `GET` then `PUT` of an untouched leaf `.md` is a no-op, and a title containing `"` survives the round trip. - Upload returns a `/u/...` URL that `#show` then serves. -## Open questions - -- **Should Sections be writable in v1?** The Omarchy manual needs them, since its - four part dividers are Sections and currently exist nowhere in git. - ## Out of scope Picture leaves, book create/destroy, user management, and reading via JSON beyond From 74d7c27a23352f0e1f37caae35cf45f498ec5781 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:43:50 +0200 Subject: [PATCH 03/16] fix: detect unchanged markdown bodies in Leaf#edit 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 --- app/models/leaf/editable.rb | 7 ++++++- test/models/leaf/editable_test.rb | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/models/leaf/editable.rb b/app/models/leaf/editable.rb index d00f2a69..2850b060 100644 --- a/app/models/leaf/editable.rb +++ b/app/models/leaf/editable.rb @@ -28,7 +28,12 @@ def last_edit_old? def will_change_leafable?(leafable_params) leafable_params.select do |key, value| - leafable.attributes[key.to_s] != value + # Markdown attributes live in an association, not a column, so attributes[] can't see them + if markdown = leafable.safe_markdown_attribute(key) + markdown.content.to_s != value.to_s + else + leafable.attributes[key.to_s] != value + end end.present? end diff --git a/test/models/leaf/editable_test.rb b/test/models/leaf/editable_test.rb index b6546420..1fcdcd5b 100644 --- a/test/models/leaf/editable_test.rb +++ b/test/models/leaf/editable_test.rb @@ -46,6 +46,26 @@ class Leaf::EditableTest < ActiveSupport::TestCase end end + test "re-sending an identical body doesn't create a revision" do + leaves(:welcome_page).edit leafable_params: { body: "New body" } + + travel 1.hour do + assert_no_difference -> { Edit.count } do + leaves(:welcome_page).edit leafable_params: { body: "New body" } + end + end + end + + test "re-sending an identical section body doesn't create a revision" do + leaves(:welcome_section).edit leafable_params: { body: "New body" } + + travel 1.hour do + assert_no_difference -> { Edit.count } do + leaves(:welcome_section).edit leafable_params: { body: "New body" } + end + end + end + test "editing a leafable with an attachment includes the attachments in the new version" do assert leaves(:reading_picture).picture.image.attached? From 40562a7bb2b8e24a59a88429d04d8212f2cc4540 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:45:37 +0200 Subject: [PATCH 04/16] fix: escape titles in the .md front matter title: "<%= @leaf.title %>" emitted invalid YAML whenever a title contained a double quote, and ERB's HTML escaping turned & into & 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 --- app/views/books/show.md.erb | 4 ++-- app/views/leafables/show.md.erb | 2 +- test/controllers/leafables_controller_test.rb | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/views/books/show.md.erb b/app/views/books/show.md.erb index a8e7cf4f..d267d0dc 100644 --- a/app/views/books/show.md.erb +++ b/app/views/books/show.md.erb @@ -1,6 +1,6 @@ --- -title: "<%= @book.title %>" -author: "<%= @book.author %>" +title: <%= raw JSON.generate(@book.title) %> +author: <%= raw JSON.generate(@book.author.to_s) %> url: "<%= book_slug_url(@book) %>" --- diff --git a/app/views/leafables/show.md.erb b/app/views/leafables/show.md.erb index b0fc977f..5ae8a3b2 100644 --- a/app/views/leafables/show.md.erb +++ b/app/views/leafables/show.md.erb @@ -1,5 +1,5 @@ --- -title: "<%= @leaf.title %>" +title: <%= raw JSON.generate(@leaf.title) %> url: "<%= leafable_slug_url(@leaf) %>" --- diff --git a/test/controllers/leafables_controller_test.rb b/test/controllers/leafables_controller_test.rb index d9e383c7..948e354b 100644 --- a/test/controllers/leafables_controller_test.rb +++ b/test/controllers/leafables_controller_test.rb @@ -86,6 +86,16 @@ class LeafablesControllerTest < ActionDispatch::IntegrationTest assert_select "figure img[src*=\"pixel.bmp\"]" end + test "show with markdown format escapes the title for the front matter" do + leaves(:welcome_page).update!(title: %(A "quoted" title & more)) + + get leafable_slug_path(leaves(:welcome_page), format: :md) + + assert_response :success + assert_in_body 'title: "A \"quoted\" title & more"' + assert_not_in_body "&" + end + test "show with markdown format does not escape HTML entities" do leaves(:welcome_page).leafable.update!(body: "This has a link") From 3e77d55b1bcd2bb26c7c4cc0c1853b9ef9b79986 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:46:31 +0200 Subject: [PATCH 05/16] feat: give every user a resettable bearer key 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 --- app/models/user.rb | 1 + .../20260813124551_add_bearer_key_to_users.rb | 12 +++ db/schema.rb | 78 ++++++++++--------- 3 files changed, 53 insertions(+), 38 deletions(-) create mode 100644 db/migrate/20260813124551_add_bearer_key_to_users.rb diff --git a/app/models/user.rb b/app/models/user.rb index e6e6ad6b..830f0d42 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_secure_password validations: false + has_secure_token :bearer_key has_many :accesses, dependent: :destroy has_many :books, through: :accesses diff --git a/db/migrate/20260813124551_add_bearer_key_to_users.rb b/db/migrate/20260813124551_add_bearer_key_to_users.rb new file mode 100644 index 00000000..5ad422f9 --- /dev/null +++ b/db/migrate/20260813124551_add_bearer_key_to_users.rb @@ -0,0 +1,12 @@ +class AddBearerKeyToUsers < ActiveRecord::Migration[8.2] + def up + add_column :users, :bearer_key, :string + add_index :users, :bearer_key, unique: true + + User.find_each(&:regenerate_bearer_key) + end + + def down + remove_column :users, :bearer_key + end +end diff --git a/db/schema.rb b/db/schema.rb index e0331993..0316087f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,42 +10,42 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_09_28_005927) do +ActiveRecord::Schema[8.2].define(version: 2026_08_13_124551) do create_table "accesses", force: :cascade do |t| - t.integer "user_id", null: false t.integer "book_id", null: false - t.string "level", null: false t.datetime "created_at", null: false + t.string "level", null: false t.datetime "updated_at", null: false + t.integer "user_id", null: false t.index ["book_id"], name: "index_accesses_on_book_id" t.index ["user_id", "book_id"], name: "index_accesses_on_user_id_and_book_id", unique: true t.index ["user_id"], name: "index_accesses_on_user_id" end create_table "accounts", force: :cascade do |t| - t.string "name", null: false - t.string "join_code", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.text "custom_styles" + t.string "join_code", null: false + t.string "name", null: false + t.datetime "updated_at", null: false end create_table "action_text_markdowns", force: :cascade do |t| - t.string "record_type", null: false - t.integer "record_id", null: false - t.string "name", null: false t.text "content", default: "", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.integer "record_id", null: false + t.string "record_type", null: false t.datetime "updated_at", null: false t.index ["record_type", "record_id"], name: "index_action_text_markdowns_on_record" end create_table "active_storage_attachments", force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.bigint "record_id", null: false t.bigint "blob_id", null: false t.datetime "created_at", null: false + t.string "name", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false t.string "slug" t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true @@ -53,14 +53,14 @@ end create_table "active_storage_blobs", force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" - t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -71,24 +71,24 @@ end create_table "books", force: :cascade do |t| - t.string "title", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "subtitle" t.string "author" + t.datetime "created_at", null: false + t.boolean "everyone_access", default: true, null: false t.boolean "published", default: false, null: false t.string "slug", null: false - t.boolean "everyone_access", default: true, null: false + t.string "subtitle" t.string "theme", default: "blue", null: false + t.string "title", null: false + t.datetime "updated_at", null: false t.index ["published"], name: "index_books_on_published" end create_table "edits", force: :cascade do |t| - t.integer "leaf_id", null: false - t.string "leafable_type", null: false - t.integer "leafable_id", null: false t.string "action", null: false t.datetime "created_at", null: false + t.integer "leaf_id", null: false + t.integer "leafable_id", null: false + t.string "leafable_type", null: false t.datetime "updated_at", null: false t.index ["leaf_id"], name: "index_edits_on_leaf_id" t.index ["leafable_type", "leafable_id"], name: "index_edits_on_leafable" @@ -96,13 +96,13 @@ create_table "leaves", force: :cascade do |t| t.integer "book_id", null: false - t.string "leafable_type", null: false + t.datetime "created_at", null: false t.integer "leafable_id", null: false + t.string "leafable_type", null: false t.float "position_score", null: false t.string "status", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "title", null: false + t.datetime "updated_at", null: false t.index ["book_id"], name: "index_leaves_on_book_id" t.index ["leafable_type", "leafable_id"], name: "index_leafs_on_leafable" end @@ -113,38 +113,40 @@ end create_table "pictures", force: :cascade do |t| + t.string "caption" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "caption" end create_table "sections", force: :cascade do |t| + t.text "body" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "theme" - t.text "body" + t.datetime "updated_at", null: false end create_table "sessions", force: :cascade do |t| - t.integer "user_id", null: false - t.string "token", null: false + t.datetime "created_at", null: false t.string "ip_address" - t.string "user_agent" t.datetime "last_active_at", null: false - t.datetime "created_at", null: false + t.string "token", null: false t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false t.index ["token"], name: "index_sessions_on_token", unique: true t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "users", force: :cascade do |t| - t.string "name", null: false + t.boolean "active", default: true + t.string "bearer_key" + t.datetime "created_at", null: false t.string "email_address", null: false + t.string "name", null: false t.string "password_digest", null: false t.integer "role", null: false - t.boolean "active", default: true - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["bearer_key"], name: "index_users_on_bearer_key", unique: true t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["name"], name: "index_users_on_name", unique: true end From 4c9159133819e0b5bb469efdc4ddd29c5208c10b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:49:45 +0200 Subject: [PATCH 06/16] feat: authenticate API clients with a bearer key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorization: Bearer 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 --- app/controllers/books_controller.rb | 1 + app/controllers/concerns/authentication.rb | 33 ++++++++++-- test/controllers/api_authentication_test.rb | 60 +++++++++++++++++++++ test/fixtures/users.yml | 4 ++ test/test_helpers/session_test_helper.rb | 5 ++ 5 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 test/controllers/api_authentication_test.rb diff --git a/app/controllers/books_controller.rb b/app/controllers/books_controller.rb index eda26210..6fe2920c 100644 --- a/app/controllers/books_controller.rb +++ b/app/controllers/books_controller.rb @@ -1,5 +1,6 @@ class BooksController < ApplicationController allow_unauthenticated_access only: %i[ index show ] + allow_bearer_key_access only: :show before_action :ensure_index_is_not_empty, only: :index before_action :set_book, only: %i[ show edit update destroy ] diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 96c7ee74..9d2cb118 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -6,7 +6,7 @@ module Authentication before_action :require_authentication helper_method :signed_in? - protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? } + protect_from_forgery with: :exception, unless: -> { authenticated_by.bearer_key? } end class_methods do @@ -19,6 +19,10 @@ def allow_unauthenticated_access(**options) skip_before_action :require_authentication, **options before_action :restore_authentication, **options end + + def allow_bearer_key_access(**options) + prepend_before_action :permit_bearer_key_authentication, **options + end end private @@ -33,12 +37,35 @@ def require_authentication def restore_authentication if session = find_session_by_cookie resume_session session + elsif user = find_user_by_bearer_key + authenticated_as_api_client user + end + end + + # Bearer keys only authenticate on controllers that opted in via + # allow_bearer_key_access. Everywhere else the request stays anonymous. + def permit_bearer_key_authentication + @bearer_key_authentication_permitted = true + end + + def find_user_by_bearer_key + if @bearer_key_authentication_permitted + authenticate_with_http_token { |token, _options| User.active.find_by(bearer_key: token) } end end + def authenticated_as_api_client(user) + Current.user = user + set_authenticated_by :bearer_key + end + def request_authentication - session[:return_to_after_authenticating] = request.url - redirect_to new_session_url + if request.authorization.present? || !request.format.html? + head :unauthorized + else + session[:return_to_after_authenticating] = request.url + redirect_to new_session_url + end end def redirect_signed_in_user_to_root diff --git a/test/controllers/api_authentication_test.rb b/test/controllers/api_authentication_test.rb new file mode 100644 index 00000000..a7635c1f --- /dev/null +++ b/test/controllers/api_authentication_test.rb @@ -0,0 +1,60 @@ +require "test_helper" + +class ApiAuthenticationTest < ActionDispatch::IntegrationTest + # The handbook fixture is unpublished, so anonymous requests can't see it + # and bearer-key requests only can when the key authenticates. + + test "a bearer key authenticates where the controller allows it" do + get book_slug_path(books(:handbook)), headers: bearer_key_header(:david) + + assert_response :success + end + + test "a bearer key works without a session cookie or CSRF token" do + get book_slug_path(books(:handbook)), headers: bearer_key_header(:jz) + + assert_response :success + assert_not cookies[:session_token].present? + end + + test "a bad key stays anonymous" do + get book_slug_path(books(:handbook)), headers: { "Authorization" => "Bearer wrong" } + + assert_response :not_found + end + + test "a reset key stops working" do + old_key = users(:david).bearer_key + users(:david).regenerate_bearer_key + + get book_slug_path(books(:handbook)), headers: { "Authorization" => "Bearer #{old_key}" } + + assert_response :not_found + end + + test "a deactivated user's key stops working" do + users(:david).deactivate + + get book_slug_path(books(:handbook)), headers: bearer_key_header(:david) + + assert_response :not_found + end + + test "a valid key does not authenticate on controllers that haven't opted in" do + get edit_book_path(books(:handbook)), headers: bearer_key_header(:david) + + assert_response :unauthorized + end + + test "requests with a key get unauthorized instead of a login redirect" do + get users_path, headers: bearer_key_header(:david) + + assert_response :unauthorized + end + + test "browser requests without a key still get the login redirect" do + get edit_book_path(books(:handbook)) + + assert_redirected_to new_session_url + end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 201fec46..13d087ec 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -2,24 +2,28 @@ david: name: David + bearer_key: david_bearer_key email_address: david@example.com password_digest: <%= password_digest %> role: administrator jason: name: Jason + bearer_key: jason_bearer_key email_address: jason@example.com password_digest: <%= password_digest %> role: administrator jz: name: JZ + bearer_key: jz_bearer_key email_address: jz@example.com password_digest: <%= password_digest %> role: member kevin: name: Kevin + bearer_key: kevin_bearer_key email_address: kevin@example.com password_digest: <%= password_digest %> role: member diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index 15e98587..b6e836cb 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -13,4 +13,9 @@ def sign_out delete session_url assert_not cookies[:session_token].present? end + + def bearer_key_header(user) + user = users(user) unless user.is_a? User + { "Authorization" => "Bearer #{user.bearer_key}" } + end end From 300375d4e37a9204a32004e79c507554258df29c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:50:09 +0200 Subject: [PATCH 07/16] feat: track leaves by external id 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 --- db/migrate/20260813124952_add_external_id_to_leaves.rb | 6 ++++++ db/schema.rb | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260813124952_add_external_id_to_leaves.rb diff --git a/db/migrate/20260813124952_add_external_id_to_leaves.rb b/db/migrate/20260813124952_add_external_id_to_leaves.rb new file mode 100644 index 00000000..505bff52 --- /dev/null +++ b/db/migrate/20260813124952_add_external_id_to_leaves.rb @@ -0,0 +1,6 @@ +class AddExternalIdToLeaves < ActiveRecord::Migration[8.2] + def change + add_column :leaves, :external_id, :string + add_index :leaves, [ :book_id, :external_id ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 0316087f..aebd65a4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_08_13_124551) do +ActiveRecord::Schema[8.2].define(version: 2026_08_13_124952) do create_table "accesses", force: :cascade do |t| t.integer "book_id", null: false t.datetime "created_at", null: false @@ -97,12 +97,14 @@ create_table "leaves", force: :cascade do |t| t.integer "book_id", null: false t.datetime "created_at", null: false + t.string "external_id" t.integer "leafable_id", null: false t.string "leafable_type", null: false t.float "position_score", null: false t.string "status", null: false t.string "title", null: false t.datetime "updated_at", null: false + t.index ["book_id", "external_id"], name: "index_leaves_on_book_id_and_external_id", unique: true t.index ["book_id"], name: "index_leaves_on_book_id" t.index ["leafable_type", "leafable_id"], name: "index_leafs_on_leafable" end From d7765c648f92e3138140383f28d3c41b3168b44a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:52:17 +0200 Subject: [PATCH 08/16] feat: Leaf::Document, the .md wire format for leaves 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 --- app/models/leaf/document.rb | 42 +++++++++++++++ app/views/leafables/show.md.erb | 7 +-- test/models/leaf/document_test.rb | 87 +++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 app/models/leaf/document.rb create mode 100644 test/models/leaf/document_test.rb diff --git a/app/models/leaf/document.rb b/app/models/leaf/document.rb new file mode 100644 index 00000000..1341d937 --- /dev/null +++ b/app/models/leaf/document.rb @@ -0,0 +1,42 @@ +class Leaf::Document + class Malformed < StandardError; end + + FRONT_MATTER_DELIMITER = "\n---\n" + + attr_reader :title, :position, :external_id, :body, :url + + # The .md wire format: YAML front matter, then the body, verbatim. The parser + # takes the first closing delimiter and exactly one blank line after it, so + # bodies containing --- lines round-trip untouched. + def self.parse(text) + raise Malformed, "missing front matter" unless text.start_with?("---\n") + + front, delimiter, body = text[4..].partition(FRONT_MATTER_DELIMITER) + raise Malformed, "missing closing front matter delimiter" if delimiter.empty? + + attributes = YAML.safe_load(front) + raise Malformed, "front matter is not a mapping" unless attributes.is_a?(Hash) + + new title: attributes["title"]&.to_s, position: attributes["position"]&.to_i, + external_id: attributes["external_id"]&.to_s, body: body.delete_prefix("\n") + rescue Psych::Exception => error + raise Malformed, error.message + end + + def self.from(leaf, url: nil) + new title: leaf.title, external_id: leaf.external_id, body: leaf.leafable.markable.to_s, url: url + end + + def initialize(title:, body:, position: nil, external_id: nil, url: nil) + @title, @body, @position, @external_id, @url = title, body, position, external_id, url + end + + def to_s + lines = [ "---" ] + lines << "title: #{JSON.generate(title)}" + lines << "url: #{JSON.generate(url)}" if url + lines << "---" + + "#{lines.join("\n")}\n\n#{body}" + end +end diff --git a/app/views/leafables/show.md.erb b/app/views/leafables/show.md.erb index 5ae8a3b2..32e21edb 100644 --- a/app/views/leafables/show.md.erb +++ b/app/views/leafables/show.md.erb @@ -1,6 +1 @@ ---- -title: <%= raw JSON.generate(@leaf.title) %> -url: "<%= leafable_slug_url(@leaf) %>" ---- - -<%= raw @leaf.leafable.markable %> +<%= raw Leaf::Document.from(@leaf, url: leafable_slug_url(@leaf)) %> \ No newline at end of file diff --git a/test/models/leaf/document_test.rb b/test/models/leaf/document_test.rb new file mode 100644 index 00000000..7d4dad42 --- /dev/null +++ b/test/models/leaf/document_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class Leaf::DocumentTest < ActiveSupport::TestCase + test "generates front matter and body from a leaf" do + document = Leaf::Document.from(leaves(:welcome_page), url: "http://example.com/1/handbook/2/welcome") + + assert_equal <<~MD.chomp, document.to_s + --- + title: "Welcome to The Handbook!" + url: "http://example.com/1/handbook/2/welcome" + --- + + This is _such_ a great handbook. + MD + end + + test "parses back what it generates, byte for byte" do + document = Leaf::Document.from(leaves(:welcome_page)) + parsed = Leaf::Document.parse(document.to_s) + + assert_equal document.title, parsed.title + assert_equal document.body, parsed.body + assert_equal document.to_s, parsed.to_s + end + + test "titles with quotes survive the round trip" do + document = Leaf::Document.new(title: %(A "quoted" title), body: "Body") + parsed = Leaf::Document.parse(document.to_s) + + assert_equal %(A "quoted" title), parsed.title + end + + test "bodies containing front matter delimiters survive" do + body = "Before\n\n---\n\nAfter the rule\n---\nmore" + document = Leaf::Document.new(title: "Rules", body: body) + + assert_equal body, Leaf::Document.parse(document.to_s).body + end + + test "bodies keep their exact leading and trailing whitespace" do + body = "\nStarts blank, ends with two newlines\n\n" + document = Leaf::Document.new(title: "Space", body: body) + parsed = Leaf::Document.parse(document.to_s) + + assert_equal body, parsed.body + assert_equal document.to_s, parsed.to_s + end + + test "parses hand-authored YAML front matter" do + parsed = Leaf::Document.parse(<<~MD) + --- + title: Monitors + position: 27 + external_id: monitors.md + --- + + How to configure monitors. + MD + + assert_equal "Monitors", parsed.title + assert_equal 27, parsed.position + assert_equal "monitors.md", parsed.external_id + assert_equal "How to configure monitors.\n", parsed.body + end + + test "ignores url and unknown front matter keys" do + parsed = Leaf::Document.parse("---\ntitle: T\nurl: http://example.com/x\nwhatever: else\n---\n\nBody") + + assert_equal "T", parsed.title + assert_nil parsed.url + assert_equal "Body", parsed.body + end + + test "rejects documents without front matter" do + assert_raises Leaf::Document::Malformed do + Leaf::Document.parse("Just a body") + end + + assert_raises Leaf::Document::Malformed do + Leaf::Document.parse("---\ntitle: unclosed\n\nBody") + end + + assert_raises Leaf::Document::Malformed do + Leaf::Document.parse("---\n- just\n- a\n- list\n---\n\nBody") + end + end +end From 3e9c6a078f9d4b5a645415a679782546cda72d8b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:54:14 +0200 Subject: [PATCH 09/16] feat: leaves manifest for sync clients 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 --- app/controllers/books/leaves_controller.rb | 9 ++++ app/views/books/leaves/index.json.jbuilder | 9 ++++ config/routes.rb | 2 + .../books/leaves_controller_test.rb | 41 +++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 app/controllers/books/leaves_controller.rb create mode 100644 app/views/books/leaves/index.json.jbuilder create mode 100644 test/controllers/books/leaves_controller_test.rb diff --git a/app/controllers/books/leaves_controller.rb b/app/controllers/books/leaves_controller.rb new file mode 100644 index 00000000..0b3ae6e1 --- /dev/null +++ b/app/controllers/books/leaves_controller.rb @@ -0,0 +1,9 @@ +class Books::LeavesController < ApplicationController + include BookScoped + + allow_bearer_key_access only: :index + + def index + @leaves = @book.leaves.active.with_leafables.positioned + end +end diff --git a/app/views/books/leaves/index.json.jbuilder b/app/views/books/leaves/index.json.jbuilder new file mode 100644 index 00000000..7ab9492e --- /dev/null +++ b/app/views/books/leaves/index.json.jbuilder @@ -0,0 +1,9 @@ +json.array! @leaves.each_with_index.to_a do |(leaf, index)| + json.id leaf.id + json.leafable_type leaf.leafable_type + json.title leaf.title + json.slug leaf.slug + json.position index + json.external_id leaf.external_id + json.url leafable_slug_url(leaf) +end diff --git a/config/routes.rb b/config/routes.rb index 6a759da6..f7aa721e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,8 @@ resource :bookmark, controller: "books/bookmarks", only: :show scope module: "books" do + resources :leaves, only: :index + namespace :leaves do resources :moves, only: :create end diff --git a/test/controllers/books/leaves_controller_test.rb b/test/controllers/books/leaves_controller_test.rb new file mode 100644 index 00000000..f4d54c11 --- /dev/null +++ b/test/controllers/books/leaves_controller_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +class Books::LeavesControllerTest < ActionDispatch::IntegrationTest + test "index returns the ordered manifest" do + get book_leaves_path(books(:handbook), format: :json), headers: bearer_key_header(:david) + + assert_response :success + + manifest = response.parsed_body + assert_equal books(:handbook).leaves.active.count, manifest.size + assert_equal (0...manifest.size).to_a, manifest.map { it["position"] } + + welcome = manifest.find { it["id"] == leaves(:welcome_page).id } + assert_equal "Page", welcome["leafable_type"] + assert_equal "Welcome to The Handbook!", welcome["title"] + assert_equal "welcome-to-the-handbook", welcome["slug"] + assert_equal leafable_slug_url(leaves(:welcome_page)), welcome["url"] + end + + test "index excludes trashed leaves" do + leaves(:welcome_page).trashed! + + get book_leaves_path(books(:handbook), format: :json), headers: bearer_key_header(:david) + + assert_not_includes response.parsed_body.map { it["id"] }, leaves(:welcome_page).id + end + + test "index requires authentication" do + get book_leaves_path(books(:handbook), format: :json) + + assert_response :unauthorized + end + + test "index also works with a signed-in session" do + sign_in :david + + get book_leaves_path(books(:handbook), format: :json) + + assert_response :success + end +end From 3c2649f7343dec5e1c890e1bef16b0f226f011e1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:58:32 +0200 Subject: [PATCH 10/16] feat: write pages through the leaf .md document format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/controllers/leafables_controller.rb | 71 ++++++++++-- app/controllers/pages_controller.rb | 6 +- app/views/leafables/show.json.jbuilder | 6 + test/controllers/pages_api_test.rb | 146 ++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 app/views/leafables/show.json.jbuilder create mode 100644 test/controllers/pages_api_test.rb diff --git a/app/controllers/leafables_controller.rb b/app/controllers/leafables_controller.rb index c1aa3915..175c6bad 100644 --- a/app/controllers/leafables_controller.rb +++ b/app/controllers/leafables_controller.rb @@ -1,18 +1,29 @@ class LeafablesController < ApplicationController allow_unauthenticated_access only: :show + allow_bearer_key_access only: %i[ show create update destroy ] include SetBookLeaf before_action :ensure_editable, except: :show - before_action :broadcast_being_edited_indicator, only: :update + before_action :broadcast_being_edited_indicator, only: :update, unless: -> { api_request? } + + rescue_from Leaf::Document::Malformed do |error| + render plain: error.message, status: :unprocessable_entity + end def new @leafable = new_leafable end def create - @leaf = @book.press new_leafable, leaf_params - position_new_leaf @leaf + if api_request? && @leaf = leaf_with_external_id + revise_leaf + render_leaf + else + @leaf = @book.press new_leafable, leaf_params.with_defaults(default_leaf_params) + position_leaf + render_leaf status: :created if api_request? + end end def show @@ -26,11 +37,12 @@ def edit end def update - @leaf.edit leafable_params: leafable_params, leaf_params: leaf_params + revise_leaf respond_to do |format| format.turbo_stream { render } format.html { head :no_content } + format.any(:md, :json) { render_leaf } end end @@ -40,12 +52,55 @@ def destroy respond_to do |format| format.turbo_stream { render } format.html { redirect_to book_slug_url(@book) } + format.any(:md, :json) { head :no_content } end end private + def api_request? + request.format.md? || request.format.json? + end + + def leaf_document + @leaf_document ||= Leaf::Document.parse(request.raw_post) if request.format.md? + end + + def external_id + leaf_document ? leaf_document.external_id : params[:external_id].presence + end + + def leaf_with_external_id + @book.leaves.find_by(external_id: external_id) if external_id + end + + def revise_leaf + @leaf.active! if @leaf.trashed? + @leaf.edit leafable_params: leafable_params, leaf_params: leaf_params + position_leaf + end + + def position_leaf + if position = requested_position + @leaf.move_to_position position + end + end + + def requested_position + leaf_document ? leaf_document.position : params[:position]&.to_i + end + + def render_leaf(status: :ok) + respond_to do |format| + format.any(:md, :json) { render :show, status: status } + end + end + def leaf_params - default_leaf_params.merge params.fetch(:leaf, {}).permit(:title) + if leaf_document + { title: leaf_document.title, external_id: leaf_document.external_id }.compact + else + params.fetch(:leaf, {}).permit(:title).to_h.symbolize_keys.merge({ external_id: external_id }.compact) + end end def default_leaf_params @@ -60,12 +115,6 @@ def leafable_params raise NotImplementedError.new "Implement in subclass" end - def position_new_leaf(leaf) - if position = params[:position]&.to_i - leaf.move_to_position position - end - end - def broadcast_being_edited_indicator Turbo::StreamsChannel.broadcast_render_later_to @leaf, :being_edited, partial: "leaves/being_edited_by", locals: { leaf: @leaf, user: Current.user } diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index ca615270..281c36c8 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -15,6 +15,10 @@ def new_leafable end def leafable_params - params.fetch(:page, {}).permit(:body) + if leaf_document + { body: leaf_document.body } + else + params.fetch(:page, {}).permit(:body) + end end end diff --git a/app/views/leafables/show.json.jbuilder b/app/views/leafables/show.json.jbuilder new file mode 100644 index 00000000..5f55be70 --- /dev/null +++ b/app/views/leafables/show.json.jbuilder @@ -0,0 +1,6 @@ +json.id @leaf.id +json.leafable_type @leaf.leafable_type +json.title @leaf.title +json.slug @leaf.slug +json.external_id @leaf.external_id +json.url leafable_slug_url(@leaf) diff --git a/test/controllers/pages_api_test.rb b/test/controllers/pages_api_test.rb new file mode 100644 index 00000000..8ddc5289 --- /dev/null +++ b/test/controllers/pages_api_test.rb @@ -0,0 +1,146 @@ +require "test_helper" + +class PagesApiTest < ActionDispatch::IntegrationTest + test "creating a page from a document" do + assert_difference -> { books(:handbook).leaves.count }, +1 do + post_document document(title: "Monitors", body: "How to configure monitors.", external_id: "monitors.md") + end + + assert_response :created + + leaf = books(:handbook).leaves.find_by!(external_id: "monitors.md") + assert_equal "Monitors", leaf.title + assert_equal "How to configure monitors.", leaf.page.body.content.to_s + assert_in_body 'title: "Monitors"' + end + + test "creating with a position lands the page there" do + post_document document(title: "First!", body: "Body", external_id: "first.md", position: 0) + + assert_equal "First!", books(:handbook).leaves.active.positioned.first.title + end + + test "re-posting the same external_id updates instead of duplicating" do + post_document document(title: "Monitors", body: "Original", external_id: "monitors.md") + assert_response :created + + assert_no_difference -> { books(:handbook).leaves.count } do + post_document document(title: "Monitors", body: "Updated", external_id: "monitors.md") + end + + assert_response :success + assert_equal "Updated", books(:handbook).leaves.find_by!(external_id: "monitors.md").page.body.content.to_s + end + + test "re-posting identical content records no edit" do + post_document document(title: "Monitors", body: "Same", external_id: "monitors.md") + + travel 1.hour do + assert_no_difference -> { Edit.count } do + post_document document(title: "Monitors", body: "Same", external_id: "monitors.md") + end + end + end + + test "re-posting the external_id of a trashed leaf restores it" do + post_document document(title: "Monitors", body: "Body", external_id: "monitors.md") + leaf = books(:handbook).leaves.find_by!(external_id: "monitors.md") + leaf.trashed! + + assert_no_difference -> { books(:handbook).leaves.count } do + post_document document(title: "Monitors", body: "Body", external_id: "monitors.md") + end + + assert leaf.reload.active? + end + + test "updating a page by id" do + put book_page_path(books(:handbook), leaves(:welcome_page), format: :md), + params: document(title: "Welcome!", body: "New body"), headers: markdown_headers(:david) + + assert_response :success + assert_equal "Welcome!", leaves(:welcome_page).reload.title + assert_equal "New body", leaves(:welcome_page).page.body.content.to_s + end + + test "putting back what you get is a no-op" do + get leafable_slug_path(leaves(:welcome_page), format: :md), headers: bearer_key_header(:david) + exported = response.body + + travel 1.hour do + assert_no_difference -> { Edit.count } do + put book_page_path(books(:handbook), leaves(:welcome_page), format: :md), + params: exported, headers: markdown_headers(:david) + end + end + + get leafable_slug_path(leaves(:welcome_page), format: :md), headers: bearer_key_header(:david) + assert_equal exported, response.body + end + + test "titles with quotes survive the round trip" do + post_document document(title: %(A "quoted" title), body: "Body", external_id: "quoted.md") + leaf = books(:handbook).leaves.find_by!(external_id: "quoted.md") + + get leafable_slug_path(leaf, format: :md), headers: bearer_key_header(:david) + put book_page_path(books(:handbook), leaf, format: :md), params: response.body, headers: markdown_headers(:david) + + assert_equal %(A "quoted" title), leaf.reload.title + end + + test "destroy trashes, not destroys" do + assert_no_difference -> { Leaf.count } do + delete book_page_path(books(:handbook), leaves(:welcome_page), format: :json), + headers: bearer_key_header(:david) + end + + assert_response :no_content + assert leaves(:welcome_page).reload.trashed? + end + + test "a malformed document is rejected" do + post_document "No front matter here" + + assert_response :unprocessable_entity + end + + test "a reader's key cannot write" do + post_document document(title: "Nope", body: "Nope"), user: :jz + + assert_response :forbidden + end + + test "an unrelated user's key cannot even see a private book" do + books(:handbook).accesses.where(user: users(:kevin)).delete_all + + post_document document(title: "Nope", body: "Nope"), user: :kevin + + assert_response :not_found + end + + test "bearer key writes skip CSRF protection" do + ActionController::Base.allow_forgery_protection = true + + post_document document(title: "No token", body: "Body") + + assert_response :created + ensure + ActionController::Base.allow_forgery_protection = false + end + + private + def document(title:, body:, external_id: nil, position: nil) + front = [ "---", "title: #{JSON.generate(title)}" ] + front << "external_id: #{JSON.generate(external_id)}" if external_id + front << "position: #{position}" if position + (front + [ "---", "", body ]).join("\n") + end + + def post_document(doc, user: :david) + post book_pages_path(books(:handbook), format: :md), params: doc, headers: markdown_headers(user) + end + + def markdown_headers(user) + bearer_key_header(user).merge("Content-Type" => "text/markdown") + end +end From d47c7c9e1ea2952da629844b88feae906fe5fccd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 14:59:16 +0200 Subject: [PATCH 11/16] test: sections write through the JSON API 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 --- test/controllers/sections_api_test.rb | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 test/controllers/sections_api_test.rb diff --git a/test/controllers/sections_api_test.rb b/test/controllers/sections_api_test.rb new file mode 100644 index 00000000..96f9aaf1 --- /dev/null +++ b/test/controllers/sections_api_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class SectionsApiTest < ActionDispatch::IntegrationTest + test "creating a section with JSON" do + assert_difference -> { books(:handbook).leaves.count }, +1 do + post book_sections_path(books(:handbook), format: :json), + params: { leaf: { title: "The Basics" }, external_id: "the-basics", position: 0 }, + headers: bearer_key_header(:david), as: :json + end + + assert_response :created + + leaf = books(:handbook).leaves.find_by!(external_id: "the-basics") + assert_equal "Section", leaf.leafable_type + assert_equal "The Basics", leaf.title + assert_equal "The Basics", leaf.section.body + assert_equal leaf, books(:handbook).leaves.active.positioned.first + assert_equal leaf.id, response.parsed_body["id"] + end + + test "re-posting the same external_id updates instead of duplicating" do + 2.times do |round| + assert_difference -> { books(:handbook).leaves.count }, round.zero? ? +1 : 0 do + post book_sections_path(books(:handbook), format: :json), + params: { leaf: { title: "The Basics" }, section: { theme: "vol#{round}" }, external_id: "the-basics" }, + headers: bearer_key_header(:david), as: :json + end + end + + assert_equal "vol1", books(:handbook).leaves.find_by!(external_id: "the-basics").section.theme + end + + test "re-posting identical content records no edit" do + params = { leaf: { title: "The Basics" }, section: { body: "The Basics" }, external_id: "the-basics" } + + post book_sections_path(books(:handbook), format: :json), + params: params, headers: bearer_key_header(:david), as: :json + + travel 1.hour do + assert_no_difference -> { Edit.count } do + post book_sections_path(books(:handbook), format: :json), + params: params, headers: bearer_key_header(:david), as: :json + end + end + end + + test "updating a section by id" do + put book_section_path(books(:handbook), leaves(:welcome_section), format: :json), + params: { leaf: { title: "Renamed" }, section: { body: "Renamed" } }, + headers: bearer_key_header(:david), as: :json + + assert_response :success + assert_equal "Renamed", leaves(:welcome_section).reload.title + assert_equal "Renamed", leaves(:welcome_section).section.body + end + + test "a reader's key cannot write sections" do + post book_sections_path(books(:handbook), format: :json), + params: { leaf: { title: "Nope" } }, headers: bearer_key_header(:jz), as: :json + + assert_response :forbidden + end +end From cbf035335c41b3cee6cae108322bee29f118106b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 15:02:58 +0200 Subject: [PATCH 12/16] feat: bearer-key uploads, and absolute upload URLs everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/controllers/pages/uploads_controller.rb | 33 ++++++++++++++ .../markdown/uploads/create.json.jbuilder | 3 +- config/routes.rb | 6 ++- .../markdown/uploads_controller_test.rb | 5 ++- .../pages/uploads_controller_test.rb | 43 +++++++++++++++++++ 5 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 app/controllers/pages/uploads_controller.rb create mode 100644 test/controllers/pages/uploads_controller_test.rb diff --git a/app/controllers/pages/uploads_controller.rb b/app/controllers/pages/uploads_controller.rb new file mode 100644 index 00000000..adba3952 --- /dev/null +++ b/app/controllers/pages/uploads_controller.rb @@ -0,0 +1,33 @@ +class Pages::UploadsController < ApplicationController + allow_bearer_key_access + + before_action do + ActiveStorage::Current.url_options = { protocol: request.protocol, host: request.host, port: request.port } + end + + before_action :set_page, :ensure_editable + + # Same attach-and-render as ActionText::Markdown::UploadsController, but the + # page comes from the path instead of a signed GlobalID, which no script can mint. + def create + @markdown = @page.body + @markdown.uploads.attach [ params[:file] ] + @markdown.save! + + @upload = @markdown.uploads.attachments.last + + render "action_text/markdown/uploads/create", status: :created, formats: :json + end + + private + def set_page + @book = Book.accessable_or_published.find(params[:book_id]) + leafable = @book.leaves.active.find(params[:page_id]).leafable + + head :unprocessable_entity unless @page = (leafable if leafable.is_a?(Page)) + end + + def ensure_editable + head :forbidden unless @book.editable? + end +end diff --git a/app/views/action_text/markdown/uploads/create.json.jbuilder b/app/views/action_text/markdown/uploads/create.json.jbuilder index 147122a6..4211976b 100644 --- a/app/views/action_text/markdown/uploads/create.json.jbuilder +++ b/app/views/action_text/markdown/uploads/create.json.jbuilder @@ -1,4 +1,5 @@ json.message "File uploaded successfully" json.fileName @upload.filename.to_s json.mimetype @upload.content_type -json.fileUrl @upload.slug_path +# main_app: rendered from inside the isolated ActionText namespace, where url helpers resolve against the engine +json.fileUrl main_app.action_text_markdown_upload_url(@upload.slug) diff --git a/config/routes.rb b/config/routes.rb index f7aa721e..ec35cd41 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,7 +35,11 @@ resources :sections resources :pictures - resources :pages + resources :pages do + scope module: "pages" do + resources :uploads, only: :create + end + end end get "/:id/:slug", to: "books#show", constraints: { id: /\d+/ }, as: :slugged_book diff --git a/test/controllers/action_text/markdown/uploads_controller_test.rb b/test/controllers/action_text/markdown/uploads_controller_test.rb index 93472f32..7be2e75e 100644 --- a/test/controllers/action_text/markdown/uploads_controller_test.rb +++ b/test/controllers/action_text/markdown/uploads_controller_test.rb @@ -16,8 +16,9 @@ class ActionText::Markdown::UploadsControllerTest < ActionDispatch::IntegrationT assert_response :success - # Uploads should use relative URLs, to allow for future hostname changes - assert JSON.parse(response.body)["fileUrl"].start_with?("/") + # Absolute URLs, so bodies mirrored into git render everywhere. A hostname + # change means a one-time rewrite of stored bodies. + assert JSON.parse(response.body)["fileUrl"].start_with?("http://www.example.com/u/") end test "a signed id minted for some other purpose can't be used to upload" do diff --git a/test/controllers/pages/uploads_controller_test.rb b/test/controllers/pages/uploads_controller_test.rb new file mode 100644 index 00000000..23b3c689 --- /dev/null +++ b/test/controllers/pages/uploads_controller_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +class Pages::UploadsControllerTest < ActionDispatch::IntegrationTest + test "a bearer key can upload to a page it can edit" do + assert_changes -> { ActiveStorage::Attachment.count }, +1 do + post book_page_uploads_path(books(:handbook), leaves(:welcome_page), format: :json), + params: { file: fixture_file_upload("reading.webp", "image/webp") }, + headers: bearer_key_header(:david) + end + + assert_response :created + assert response.parsed_body["fileUrl"].start_with?("http://www.example.com/u/") + end + + test "the returned URL serves publicly once the book is published" do + post book_page_uploads_path(books(:handbook), leaves(:welcome_page), format: :json), + params: { file: fixture_file_upload("reading.webp", "image/webp") }, + headers: bearer_key_header(:david) + + books(:handbook).update! published: true + + get response.parsed_body["fileUrl"] + + assert_response :redirect + assert_equal "max-age=31556952, public", response.headers["Cache-Control"] + end + + test "a reader's key cannot upload" do + post book_page_uploads_path(books(:handbook), leaves(:welcome_page), format: :json), + params: { file: fixture_file_upload("reading.webp", "image/webp") }, + headers: bearer_key_header(:jz) + + assert_response :forbidden + end + + test "uploads only attach to pages" do + post book_page_uploads_path(books(:handbook), leaves(:welcome_section), format: :json), + params: { file: fixture_file_upload("reading.webp", "image/webp") }, + headers: bearer_key_header(:david) + + assert_response :unprocessable_entity + end +end From 1be7165179df4062c52687614451e0255531023b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 15:08:34 +0200 Subject: [PATCH 13/16] fix: force UTF-8 on parsed leaf documents 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 --- app/models/leaf/document.rb | 3 +++ test/models/leaf/document_test.rb | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/app/models/leaf/document.rb b/app/models/leaf/document.rb index 1341d937..614b309d 100644 --- a/app/models/leaf/document.rb +++ b/app/models/leaf/document.rb @@ -9,6 +9,9 @@ class Malformed < StandardError; end # takes the first closing delimiter and exactly one blank line after it, so # bodies containing --- lines round-trip untouched. def self.parse(text) + # Request bodies arrive binary-encoded; the wire format is UTF-8 + text = text.dup.force_encoding(Encoding::UTF_8) + raise Malformed, "not valid UTF-8" unless text.valid_encoding? raise Malformed, "missing front matter" unless text.start_with?("---\n") front, delimiter, body = text[4..].partition(FRONT_MATTER_DELIMITER) diff --git a/test/models/leaf/document_test.rb b/test/models/leaf/document_test.rb index 7d4dad42..3501f8a7 100644 --- a/test/models/leaf/document_test.rb +++ b/test/models/leaf/document_test.rb @@ -71,6 +71,21 @@ class Leaf::DocumentTest < ActiveSupport::TestCase assert_equal "Body", parsed.body end + test "binary-encoded requests with UTF-8 content parse" do + raw = "---\ntitle: Führung\n---\n\nEm — dash".b + parsed = Leaf::Document.parse(raw) + + assert_equal "Führung", parsed.title + assert_equal "Em — dash", parsed.body + assert_equal Encoding::UTF_8, parsed.body.encoding + end + + test "rejects invalid UTF-8" do + assert_raises Leaf::Document::Malformed do + Leaf::Document.parse("---\ntitle: T\n---\n\n\xE2 broken".b) + end + end + test "rejects documents without front matter" do assert_raises Leaf::Document::Malformed do Leaf::Document.parse("Just a body") From ecda7db5cecef032ef991c41426055d8dd7eed3a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 15:17:01 +0200 Subject: [PATCH 14/16] fix: include Markdown uploads at prepare time, not on a lazy load hook 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 --- lib/rails_ext/action_text_markdown.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/rails_ext/action_text_markdown.rb b/lib/rails_ext/action_text_markdown.rb index afc0b2b5..b6b0d42a 100644 --- a/lib/rails_ext/action_text_markdown.rb +++ b/lib/rails_ext/action_text_markdown.rb @@ -40,10 +40,12 @@ module ActionText::Markdown::Uploads end end -ActiveSupport.on_load :active_storage_attachment do - class ActionText::Markdown - include ActionText::Markdown::Uploads - end +# to_prepare, not on_load(:active_storage_attachment): the load hook only fired +# once something else happened to load ActiveStorage::Attachment, leaving uploads +# undefined in a process serving an upload as its first storage-touching request. +# has_many_attached isn't defined until after the initializers, so it can't run here. +Rails.application.config.to_prepare do + ActionText::Markdown.include ActionText::Markdown::Uploads end ActiveSupport.run_load_hooks :action_text_markdown, ActionText::Markdown From a95e5cce4e0a6d4412e1cb42af3739cf590775a4 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 16:02:41 +0200 Subject: [PATCH 15/16] feat: show and reset your API key in your settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../users/bearer_keys_controller.rb | 10 ++++ app/helpers/translations_helper.rb | 1 + app/views/users/_bearer_key.html.erb | 25 +++++++++ app/views/users/profiles/edit.html.erb | 4 ++ config/routes.rb | 1 + .../users/bearer_keys_controller_test.rb | 51 +++++++++++++++++++ 6 files changed, 92 insertions(+) create mode 100644 app/controllers/users/bearer_keys_controller.rb create mode 100644 app/views/users/_bearer_key.html.erb create mode 100644 test/controllers/users/bearer_keys_controller_test.rb diff --git a/app/controllers/users/bearer_keys_controller.rb b/app/controllers/users/bearer_keys_controller.rb new file mode 100644 index 00000000..d77a3511 --- /dev/null +++ b/app/controllers/users/bearer_keys_controller.rb @@ -0,0 +1,10 @@ +class Users::BearerKeysController < ApplicationController + include UserScoped + + before_action :ensure_current_user + + def create + @user.regenerate_bearer_key + redirect_to edit_user_profile_url(@user) + end +end diff --git a/app/helpers/translations_helper.rb b/app/helpers/translations_helper.rb index 89ce4d4e..6e281798 100644 --- a/app/helpers/translations_helper.rb +++ b/app/helpers/translations_helper.rb @@ -1,5 +1,6 @@ module TranslationsHelper TRANSLATIONS = { + bearer_key: { "🇺🇸": "Your API key for scripts that write to your books", "🇪🇸": "Tu clave de API para scripts que escriben en tus libros", "🇫🇷": "Votre clé d'API pour les scripts qui écrivent dans vos livres", "🇮🇳": "आपकी API कुंजी उन स्क्रिप्ट्स के लिए जो आपकी पुस्तकों में लिखती हैं", "🇩🇪": "Ihr API-Schlüssel für Skripte, die in Ihre Bücher schreiben", "🇧🇷": "Sua chave de API para scripts que escrevem em seus livros" }, book_author: { "🇺🇸": "Author", "🇪🇸": "Autor", "🇫🇷": "Auteur", "🇮🇳": "लेखक", "🇩🇪": "Autor", "🇧🇷": "Autor" }, book_subtitle: { "🇺🇸": "Subtitle", "🇪🇸": "Subtítulo", "🇫🇷": "Sous-titre", "🇮🇳": "उपशीर्षक", "🇩🇪": "Untertitel", "🇧🇷": "Subtítulo" }, book_title: { "🇺🇸": "Book title", "🇪🇸": "Título del libro", "🇫🇷": "Titre du livre", "🇮🇳": "पुस्तक का शीर्षक", "🇩🇪": "Buchtitel", "🇧🇷": "Título do livro" }, diff --git a/app/views/users/_bearer_key.html.erb b/app/views/users/_bearer_key.html.erb new file mode 100644 index 00000000..e06991b8 --- /dev/null +++ b/app/views/users/_bearer_key.html.erb @@ -0,0 +1,25 @@ +
+ + +
+ <%= button_to_copy_to_clipboard(user.bearer_key) do %> + <%= image_tag "copy-paste.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %> + Copy API key + <% end %> + + <%= button_to user_bearer_key_path(user), class: "btn btn--negative", data: { + turbo_confirm: "Are you sure? Any script using the current key will stop working until you give it the new one." + } do %> + <%= image_tag "arrow-reverse.svg", aria: { hidden: "true" }, size: 24, class: "colorize--black" %> + Reset API key + <% end %> +
+
diff --git a/app/views/users/profiles/edit.html.erb b/app/views/users/profiles/edit.html.erb index de743130..0325b4a5 100644 --- a/app/views/users/profiles/edit.html.erb +++ b/app/views/users/profiles/edit.html.erb @@ -55,6 +55,10 @@ <%= render "users/transfer", user: @user %> +
+ <%= render "users/bearer_key", user: @user %> +
+
<%= button_to session_path, method: :delete, class: "btn center" do %> <%= image_tag "logout.svg", aria: { hidden: true }, size: 24 %> diff --git a/config/routes.rb b/config/routes.rb index ec35cd41..6b1eb36b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,6 +63,7 @@ resources :users do scope module: "users" do resource :profile + resource :bearer_key, only: :create end end diff --git a/test/controllers/users/bearer_keys_controller_test.rb b/test/controllers/users/bearer_keys_controller_test.rb new file mode 100644 index 00000000..1bb568a2 --- /dev/null +++ b/test/controllers/users/bearer_keys_controller_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class Users::BearerKeysControllerTest < ActionDispatch::IntegrationTest + test "resetting your own key" do + sign_in :kevin + old_key = users(:kevin).bearer_key + + post user_bearer_key_path(users(:kevin)) + + assert_redirected_to edit_user_profile_url(users(:kevin)) + assert_not_equal old_key, users(:kevin).reload.bearer_key + end + + test "a reset key stops authenticating" do + old_key = users(:david).bearer_key + sign_in :david + + post user_bearer_key_path(users(:david)) + sign_out + + get book_slug_path(books(:handbook)), headers: { "Authorization" => "Bearer #{old_key}" } + assert_response :not_found + + get book_slug_path(books(:handbook)), headers: bearer_key_header(users(:david).reload) + assert_response :success + end + + test "resetting someone else's key is forbidden" do + sign_in :david + old_key = users(:kevin).bearer_key + + post user_bearer_key_path(users(:kevin)) + + assert_response :forbidden + assert_equal old_key, users(:kevin).reload.bearer_key + end + + test "your key is shown on your own settings, and nobody else's" do + sign_in :kevin + + get edit_user_profile_path(users(:kevin)) + + assert_response :success + assert_in_body users(:kevin).bearer_key + + get user_profile_path(users(:david)) + + assert_response :success + assert_not_in_body users(:david).bearer_key + end +end From 454612862fdb5f40a64933da5f57b66c87409b26 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 16:03:02 +0200 Subject: [PATCH 16/16] docs: the bearer key UI is built, not deferred Co-Authored-By: Claude Fable 5 --- plans/api.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plans/api.md b/plans/api.md index 7a006adc..82c35fba 100644 --- a/plans/api.md +++ b/plans/api.md @@ -114,8 +114,9 @@ route) only. This makes the dead CSRF guard at `app/controllers/concerns/authentication.rb:9` live — rename its `bot_key?` inquiry to `bearer_key?` to match. -Expose show/reset on the existing user profile (`Users::ProfilesController`); -until then, `regenerate_bearer_key` from the console is enough. Note that +Show and reset the key on the profile edit page, alongside the session transfer +link it resembles — never on the profile show page, which everyone in the account +can see. Note that `request_authentication` redirects to login — the bearer path must render `head :unauthorized` for API requests instead of a 302.