Delete Campfire lines through their Campfire, and explain Ping refusals

Merged
JE
1d ago
+925-14

What was wrong

bc3#14171 makes Basecamp's generic recording routes answer an empty 403 Forbidden to any write on a recording in a Ping: trash, archive, unarchive, client visibility, title, publication, others' subscriptions, spotlights. Subscribing or unsubscribing yourself, bubbling up, boosts and the Campfire line endpoints still work.

The TUI trashed whatever its Detail view, its :trash palette action and its Hey view held through Recordings().Trash. A Campfire line reaches Detail from a search hit (bc3's search returns lines, Ping lines included), from recents, a quick jump, or a basecamp tui deep link to a Ping line. Pressing t twice on one was:

  • a 403 in a Ping, once bc3#14171 deploys;
  • in a project Campfire, a trash that skipped the checks DELETE /chats/:chat/lines/:id makes on who may delete a line and until when. That works today and still will after the deploy.

The Hey view named in review can't hold a line on main: its feed is Recordings().List of messages, to-dos and documents only. It now decides by type like the others anyway.

The CLI commands that write through the gated routes would report bc3's bare 403 as "access denied", with no hint.

What changed

TUI (dev builds only):

  • Hub.RemoveRecording decides by the API's recording type. It deletes a Chat::Lines::* recording with Campfires().DeleteLine and trashes everything else.
    • It writes nothing when it isn't given a type.
    • It reads a line's Campfire from the line when the caller doesn't have it.
  • Detail reads the type and parent apart from the fields it displays, and passes them to RemoveRecording.
    • Its confirmation reads "Press t again to delete this line for good", the result says "Deleted", and the help shows t delete.
  • :trash has only the scope's label for the type, so it reads the recording first, through its bucket (bc3's bucket-less /recordings/<id>.json answers 404 for a card).
    • It refuses a line and says to press t twice in the line's view, where deletion is confirmed.
    • It trashes anything else as before.
  • Hey passes its entries' API type to RemoveRecording.

CLI: pingWriteRefusal follows the clientsForbiddenError pattern. On a 403 from a gated route it reads the recording back once. If its bucket is a Circle, the error says Basecamp doesn't change a Ping that way and names the command that works:

  • for a line, basecamp chat delete <line> --room <campfire> --in <ping>, built with deleteLineCmd so it carries --force only for machine output;
  • for the Ping's Campfire, subscriptions subscribe or subscriptions unsubscribe.

Any other 403, or a recording it can't read, converts as before. It's wired into:

  • recordings trash, archive, restore and visibility;
  • the typed nouns' trash, archive and restore;
  • subscriptions add and remove;
  • spotlight and unspotlight.

Skill: one bullet in the Pings section of skills/basecamp/SKILL.md saying which commands a Ping refuses.

Sweep

Changed: the paths above.

Left as they are:

  • TUI views of typed project items (messages, cards, docs and files, schedule, assignments, to-do lists) still use Hub.TrashRecording. Their items are never lines, and they live in a project.
  • Detail's subscribe (s) and boost (b) subscribe or unsubscribe you, or boost, which a Ping allows. Lines carry no comments, and the comment routes aren't gated.
  • recordings trash <line in a project Campfire> still trashes the line through the generic route, which bc3 keeps serving outside Pings.
    • The command promises a recoverable trash. Turning it into a permanent delete would surprise a script, and refusing it would take away a route the API serves.
    • Whether the CLI should close that route for project lines too is a call for a person.
  • chat update, chat delete, pings *, boost, subscriptions subscribe and unsubscribe use the line, Ping, boost and self-subscription endpoints, which bc3#14171 leaves open.
  • basecamp mcp sends what the agent asks to the API, from the basecamp/mcp catalog, and returns bc3's 403 as it comes.
  • tools show, update, trash, enable, disable and reposition on a Ping's Campfire now get a 404 or 403 (the same bc3 PR changes Docks::ToolsController). Reaching that means passing a Ping's Campfire ID to a dock-tool command, so the plain error stands.
  • The connector (basecamp connect) only boosts lines and reads subscriptions.
  • files move only moves items in a project's Docs & Files.

Tests

These failed on main first:

  • Detail: a line opened under the API's type, the deep-link label Campfire::Line, or no label (quick jump) is confirmed as a delete and sends only DELETE /chats/222/lines/111. That holds even when a field Detail displays won't parse.
  • :trash: on the same three labels it sends no write and returns the line refusal. It writes nothing when it can't read the recording.
  • CLI: each of the nine commands above, given a 403 and a Ping line or a Ping's Campfire on the read-back, returns the explained refusal.
    • The JSON-mode hint passes the chat delete confirmation guard it would hit.
    • The styled hint leaves --force off.
    • Run under --profile and --account, every hint names both.

These pin behavior that main already had:

  • Detail, :trash and Hey still trash a non-line.
  • A 403 on a project recording, a project line or an unreadable recording keeps the plain error.

Hub.RemoveRecording has unit tests for each branch, including that it writes nothing without a type or without a line's Campfire.

bin/ci passes on the head.

Release

The TUI is built only with -tags dev; release builds carry a stub tui command, so that half ships in no released binary. The CLI half only changes the text of errors bc3 would return anyway. Nothing needs to be released before bc3#14171 deploys. The next routine release can carry this.