feat(api): name the account's conversation to a box control token

a95a0e4c4069 · AtlantisPleb · · parent 31ca43c6ec03

feat(api): name the account's conversation to a box control token

Every Box route is addressed by conversation, so a client that does not
already know its conversation id cannot reach any of them, and nothing told
it. `GET /api/v1/user` answers the forge identity without the field, and it
sits behind `forge:write`, so a box client had no bootstrap at all and
`--conversation` was mandatory. Confirmed against production that a
`box:control`-only token gets `401 invalid_api_token` on that route.

Add `GET /api/v1/conversation` under the `box_control_api` pipeline. It answers
`conversation_id` for the calling account and creates the conversation when the
account has none, because the alternative dead-ends a headless caller: a box
token with no conversation has no route left to try. `ensure_conversation/1`
is the same idempotent call the web chat makes on first visit, so the two paths
converge on one row.

Keep the field off `/api/v1/user`. That response is GitHub-shaped, and API-001
holds every OpenAgents field there to a namespaced `openagents` object, which
is not the top-level key a box client reads. A route of our own carries it
without bending the rule, and one canonical answer beats two ways to learn the
same fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/conversation_controller.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents_web/controllers/conversation_controller_test.exs

Diff

4 files changed, +155 -0

lib/openagents_web/api_route_authority.ex modified +5

@@ -207,6 +207,11 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

207 207
      "post /api/v1/threads/:thread_id/grants" => {:required_bearer, :thread, :envelope},
208 208
      "get /api/v1/capacity" => {:required_bearer, :capacity, :legacy},
209 209
      "post /api/v1/capacity/matches" => {:required_bearer, :capacity, :legacy},
210
      # The bootstrap read for a box client. It answers a conversation, but it
211
      # belongs to the box family because that is the only reason it exists:
212
      # every other box route is addressed by conversation id, and this is
213
      # where a client that holds only `box:control` learns its own.
214
      "get /api/v1/conversation" => {:required_bearer, :box, :legacy},
210 215
      "get /api/v1/conversations/:conversation_id/boxes" => {:required_bearer, :box, :legacy},
211 216
      "post /api/v1/conversations/:conversation_id/boxes" => {:required_bearer, :box, :legacy},
212 217
      "post /api/v1/conversations/:conversation_id/boxes/fanout" =>
lib/openagents_web/controllers/conversation_controller.ex added +60

@@ -0,0 +1,60 @@

1
defmodule OpenAgentsWeb.ConversationController do
2
  @moduledoc """
3
  Names the account's conversation to a client that holds a `box:control` token.
4
5
  Every Box route is addressed by conversation, so a client that does not
6
  already know its conversation id cannot reach any of them, and nothing told
7
  it: `GET /api/v1/user` answers the forge identity, and it sits behind
8
  `forge:write`, which a box token does not carry.
9
10
  This is the one route that answers the question, deliberately. The field
11
  could have been added to `/api/v1/user` instead, but that response is
12
  GitHub-shaped, and API-001 holds every OpenAgents field there to a namespaced
13
  `openagents` object. A route of our own carries it at the top level without
14
  bending that rule, and one canonical answer beats two ways to learn the same
15
  fact.
16
17
  An account has exactly one conversation, and this endpoint creates it when
18
  it is absent rather than refusing. The alternative dead-ends a headless
19
  caller: a `box:control` token with no conversation has no route left to try,
20
  and the only way to get one would be to open a browser. `ensure_conversation/1`
21
  is the same idempotent call the web chat makes on first visit, so the two
22
  paths converge on one row instead of racing to make a second.
23
  """
24
25
  use OpenAgentsWeb, :controller
26
27
  alias OpenAgents.Conversations
28
  alias OpenAgents.Conversations.Conversation
29
30
  def show(conn, _params) do
31
    case conversation(conn.assigns.current_user) do
32
      {:ok, %Conversation{} = conversation} ->
33
        json(conn, %{
34
          "conversation_id" => conversation.id,
35
          "conversation" => %{
36
            "id" => conversation.id,
37
            "created_at" => DateTime.to_iso8601(conversation.inserted_at)
38
          }
39
        })
40
41
      :error ->
42
        conn
43
        |> put_status(:service_unavailable)
44
        |> json(%{"error" => %{"code" => "conversation_unavailable"}})
45
    end
46
  end
47
48
  defp conversation(user) do
49
    case Conversations.get_conversation_for_user(user) do
50
      %Conversation{} = conversation ->
51
        {:ok, conversation}
52
53
      nil ->
54
        case Conversations.ensure_conversation(user) do
55
          {:ok, %Conversation{} = conversation} -> {:ok, conversation}
56
          _other -> :error
57
        end
58
    end
59
  end
60
end
lib/openagents_web/router.ex modified +9

@@ -617,6 +617,15 @@ defmodule OpenAgentsWeb.Router do

617 617
    post "/threads/:thread_id/grants", ThreadController, :mint
618 618
  end
619 619
620
  # The bootstrap read for a box client: it needs a conversation id before it
621
  # can address any box route, and this is the only conversation read a
622
  # `box:control` token can reach.
623
  scope "/api/v1", OpenAgentsWeb do
624
    pipe_through :box_control_api
625
626
    get "/conversation", ConversationController, :show
627
  end
628
620 629
  scope "/api/v1/conversations/:conversation_id/boxes", OpenAgentsWeb do
621 630
    pipe_through :box_control_api
622 631
test/openagents_web/controllers/conversation_controller_test.exs added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgentsWeb.ConversationControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.{ApiTokens, Conversations}
5
6
  test "GET /api/v1/conversation names the account's conversation", %{conn: conn} do
7
    user = github_user("conversation-read")
8
    {:ok, conversation} = Conversations.ensure_conversation(user)
9
10
    response =
11
      conn
12
      |> put_req_header("authorization", "Bearer " <> box_control_token(user))
13
      |> get(~p"/api/v1/conversation")
14
      |> json_response(200)
15
16
    assert response["conversation_id"] == conversation.id
17
    assert response["conversation"]["id"] == conversation.id
18
    assert response["conversation"]["created_at"] != nil
19
  end
20
21
  test "GET /api/v1/conversation creates the conversation when the account has none", %{
22
    conn: conn
23
  } do
24
    user = github_user("conversation-bootstrap")
25
    assert Conversations.get_conversation_for_user(user) == nil
26
27
    response =
28
      conn
29
      |> put_req_header("authorization", "Bearer " <> box_control_token(user))
30
      |> get(~p"/api/v1/conversation")
31
      |> json_response(200)
32
33
    assert response["conversation_id"] == Conversations.get_conversation_for_user(user).id
34
  end
35
36
  test "GET /api/v1/conversation is idempotent", %{conn: conn} do
37
    user = github_user("conversation-idempotent")
38
    token = box_control_token(user)
39
40
    first =
41
      conn
42
      |> put_req_header("authorization", "Bearer " <> token)
43
      |> get(~p"/api/v1/conversation")
44
      |> json_response(200)
45
46
    second =
47
      build_conn()
48
      |> put_req_header("authorization", "Bearer " <> token)
49
      |> get(~p"/api/v1/conversation")
50
      |> json_response(200)
51
52
    assert first["conversation_id"] == second["conversation_id"]
53
  end
54
55
  test "GET /api/v1/conversation refuses a token without box:control", %{conn: conn} do
56
    user = github_user("conversation-wrong-scope")
57
58
    {:ok, _token, plaintext} =
59
      ApiTokens.create(user, %{name: "forge only", scopes: ["forge:write"]})
60
61
    response =
62
      conn
63
      |> put_req_header("authorization", "Bearer " <> plaintext)
64
      |> get(~p"/api/v1/conversation")
65
      |> json_response(401)
66
67
    assert response["error"]["code"] == "invalid_api_token"
68
    assert Conversations.get_conversation_for_user(user) == nil
69
  end
70
71
  test "GET /api/v1/conversation refuses an unauthenticated request", %{conn: conn} do
72
    assert conn |> get(~p"/api/v1/conversation") |> json_response(401)
73
  end
74
75
  defp box_control_token(user) do
76
    {:ok, _token, plaintext} =
77
      ApiTokens.create(user, %{name: "box control", scopes: ["box:control"]})
78
79
    plaintext
80
  end
81
end

This page updates live while a promote is in flight · changelog