lib/openagents_web/api_route_authority.ex

main at 58e6347eeb72 · 26 KB

defmodule OpenAgentsWeb.ApiRouteAuthority do
  @moduledoc """
  The single authority inventory for every `/api/v1` route.

  Each entry names one route and the principal that reaches it. The
  `OpenAgentsWeb.ApiRouteAuthorityTest` proves two ways that this inventory and
  the router agree: every live `/api/v1` route appears here exactly once, so a
  new unclassified route fails CI, and each classification matches what the
  enforcing pipeline actually does to an anonymous request.

  Each entry classifies a route three ways, and all three are mandatory: a
  route added to the router without an entry fails CI, and an entry cannot be
  written without choosing every field.

  Principals:

  - `:anonymous` — no credential is read; anyone may call.
  - `:optional_bearer` — an anonymous caller may read public repositories, and
    a scoped bearer token widens visibility to private repositories the token's
    user can read.
  - `:required_bearer` — a scoped bearer token is mandatory; anonymous calls
    are refused before the controller runs.

  Families name the resource the route serves. They group routes that must
  agree with one another, and `OpenAgentsWeb.ApiExtensionController` publishes
  them at `GET /api/v1` so a client can find every route of a family without
  guessing at paths.

  Error contracts say which refusal shape the route answers with:

  - `:envelope` — every refusal carries the six keys of
    `OpenAgentsWeb.ApiError`. `OpenAgentsWeb.ApiErrorContractTest` dispatches
    each of these routes and fails if the body is anything else.
  - `:legacy` — the route still answers with its own shape. These are the
    families the envelope has not reached yet, named rather than assumed.
  """

  principals = [:anonymous, :optional_bearer, :required_bearer]

  @type principal :: unquote(Enum.reduce(principals, &{:|, [], [&1, &2]}))
  @type error_contract :: :envelope | :legacy
  @type entry :: {principal(), atom(), error_contract()}

  @doc "The authority classification for one `METHOD /path` API v1 route."
  @spec authority(String.t(), String.t()) :: principal() | nil
  def authority(verb, path) do
    case Map.get(inventory(), "#{verb} #{path}") do
      {principal, _family, _contract} -> principal
      nil -> nil
    end
  end

  @doc "The resource family one `METHOD /path` API v1 route belongs to."
  @spec family(String.t(), String.t()) :: atom() | nil
  def family(verb, path) do
    case Map.get(inventory(), "#{verb} #{path}") do
      {_principal, family, _contract} -> family
      nil -> nil
    end
  end

  @doc "The error contract one `METHOD /path` API v1 route answers with."
  @spec error_contract(String.t(), String.t()) :: error_contract() | nil
  def error_contract(verb, path) do
    case Map.get(inventory(), "#{verb} #{path}") do
      {_principal, _family, contract} -> contract
      nil -> nil
    end
  end

  @doc "Every classified route as `{verb, path}` pairs."
  @spec routes() :: [{String.t(), String.t()}]
  def routes do
    Enum.map(inventory(), fn {key, _entry} ->
      key |> String.split(" ", parts: 2) |> List.to_tuple()
    end)
  end

  @doc """
  Every classified route as a map, sorted by path then method.

  This is what `GET /api/v1` publishes. Paths are rewritten from the router's
  `:owner` form into the `{owner}` form the rest of the API description uses,
  so a client reads one spelling everywhere.
  """
  @spec inventory_entries() :: [map()]
  def inventory_entries do
    inventory()
    |> Enum.map(fn {key, {principal, family, contract}} ->
      [verb, path] = String.split(key, " ", parts: 2)

      %{
        "method" => String.upcase(verb),
        "path" => template_path(path),
        "authority" => Atom.to_string(principal),
        "family" => Atom.to_string(family),
        "errors" => Atom.to_string(contract),
        "mutation" => verb not in ["get", "head"]
      }
    end)
    |> Enum.sort_by(&{&1["path"], &1["method"]})
  end

  @doc "Every family this inventory names, sorted."
  @spec families() :: [atom()]
  def families do
    inventory()
    |> Enum.map(fn {_key, {_principal, family, _contract}} -> family end)
    |> Enum.uniq()
    |> Enum.sort()
  end

  defp template_path(path) do
    Regex.replace(~r/:([a-z_]+)/, path, fn _whole, segment -> "{#{segment}}" end)
  end

  defp inventory do
    %{
      # Anonymous by design: the extension index is a public API description.
      "get /api/v1" => {:anonymous, :meta, :legacy},
      # An anonymous caller is answered as it always was. A caller that
      # presents a `chat:account` bearer is recognized so its own memories are
      # recalled into the turn, which is what makes this optional rather than
      # anonymous. The credential widens the context of the answer, never the
      # authority of the call.
      "post /api/v1/responses" => {:optional_bearer, :response, :envelope},
      # Anonymous by design: device authorization bootstraps credentials.
      "post /api/v1/device/authorizations" => {:anonymous, :device, :legacy},
      "post /api/v1/device/authorizations/token" => {:anonymous, :device, :legacy},
      "post /api/v1/agents/register" => {:anonymous, :agent, :legacy},
      "get /api/v1/agents/:handle" => {:anonymous, :agent, :legacy},
      # Anonymous by design: the CLI discovers validated plugin manifests.
      "get /api/v1/plugins" => {:anonymous, :plugin, :legacy},
      "get /api/v1/plugins/:name" => {:anonymous, :plugin, :legacy},
      # pipe_through :optional_forge_api — public reads, bearer-widened.
      # The ancillary issue metadata reads. Anonymous callers still read public
      # repositories; a bearer token widens them to the private repositories
      # its account may read, so an owner can read their own issue's comments.
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/comments" =>
        {:optional_bearer, :comment, :envelope},
      "get /api/v1/repos/:owner/:repo/issues/comments/:id" =>
        {:optional_bearer, :comment, :envelope},
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/labels" =>
        {:optional_bearer, :issue_label, :envelope},
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/assignees" =>
        {:optional_bearer, :issue_assignee, :envelope},
      "get /api/v1/repos/:owner/:repo/labels" => {:optional_bearer, :label, :envelope},
      "get /api/v1/repos/:owner/:repo/labels/:name" => {:optional_bearer, :label, :envelope},
      "get /api/v1/repos/:owner/:repo/milestones" => {:optional_bearer, :milestone, :envelope},
      "get /api/v1/repos/:owner/:repo/milestones/:milestone_number" =>
        {:optional_bearer, :milestone, :envelope},
      "get /api/v1/repos/:owner/:repo/assignees" => {:optional_bearer, :assignee, :envelope},
      "get /api/v1/repos/:owner/:repo/assignees/:assignee" =>
        {:optional_bearer, :assignee, :envelope},
      "get /api/v1/repos/:owner/:repo/pushes" => {:optional_bearer, :push_receipt, :envelope},
      "get /api/v1/repos/:owner/:repo/pushes/:wal_seq" =>
        {:optional_bearer, :push_receipt, :envelope},
      "get /api/v1/forum" => {:optional_bearer, :forum, :legacy},
      "get /api/v1/forum/topics" => {:optional_bearer, :forum, :legacy},
      "get /api/v1/forum/topics/:id" => {:optional_bearer, :forum, :legacy},
      "get /api/v1/repos/:owner/:repo" => {:optional_bearer, :repository, :legacy},
      "get /api/v1/repos/:owner/:repo/issues" => {:optional_bearer, :issue, :envelope},
      "get /api/v1/repos/:owner/:repo/issues/:issue_number" =>
        {:optional_bearer, :issue, :envelope},
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/activity" =>
        {:optional_bearer, :issue_activity, :envelope},
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/dependencies" =>
        {:optional_bearer, :issue_dependency, :envelope},
      "get /api/v1/repos/:owner/:repo/pulls" => {:optional_bearer, :pull_request, :legacy},
      "get /api/v1/repos/:owner/:repo/pulls/:pull_number" =>
        {:optional_bearer, :pull_request, :legacy},
      "get /api/v1/repos/:owner/:repo/pulls/:pull_number/merge-async/:operation_id" =>
        {:optional_bearer, :stack, :legacy},
      "get /api/v1/repos/:owner/:repo/stacks" => {:optional_bearer, :stack, :legacy},
      "get /api/v1/repos/:owner/:repo/stacks/:stack_number" =>
        {:optional_bearer, :stack, :legacy},
      "get /api/v1/repos/:owner/:repo/stacks/:stack_number/operations/:operation_id" =>
        {:optional_bearer, :stack, :legacy},
      "get /api/v1/repos/:owner/:repo/projectsV2" => {:optional_bearer, :project, :envelope},
      "get /api/v1/repos/:owner/:repo/projectsV2/:project_number" =>
        {:optional_bearer, :project, :envelope},
      "get /api/v1/repos/:owner/:repo/projectsV2/:project_number/items" =>
        {:optional_bearer, :project, :envelope},
      "get /api/v1/repos/:owner/:repo/projectsV2/:project_number/items/:item_id/events" =>
        {:optional_bearer, :project, :envelope},
      "get /api/v1/repos/:owner/:repo/projectsV2/:project_number/fields" =>
        {:optional_bearer, :project, :envelope},
      "get /api/v1/repos/:owner/:repo/projectsV2/:project_number/notes" =>
        {:optional_bearer, :project, :envelope},
      "get /api/v1/reputation/policy" => {:optional_bearer, :reputation, :legacy},
      "get /api/v1/reputation/keys" => {:optional_bearer, :reputation, :legacy},
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/attestations" =>
        {:optional_bearer, :reputation, :legacy},
      "get /api/v1/repos/:owner/:repo/attestations/:id" =>
        {:optional_bearer, :reputation, :legacy},
      "get /api/v1/repos/:owner/:repo/attestations/:id/verification" =>
        {:optional_bearer, :reputation, :legacy},
      "get /api/v1/repos/:owner/:repo/reputation/subjects/:subject_id" =>
        {:optional_bearer, :reputation, :legacy},
      # Scoped bearer pipelines require the route-specific token authority.
      "get /api/v1/chat/events" => {:required_bearer, :chat, :legacy},
      "post /api/v1/chat/turns" => {:required_bearer, :chat, :legacy},
      "get /api/v1/models" => {:required_bearer, :model, :envelope},
      "get /api/v1/credit" => {:required_bearer, :credit, :envelope},
      "post /api/v1/threads" => {:required_bearer, :thread, :envelope},
      "get /api/v1/threads" => {:required_bearer, :thread, :envelope},
      "get /api/v1/threads/:thread_id" => {:required_bearer, :thread, :envelope},
      "delete /api/v1/threads/:thread_id" => {:required_bearer, :thread, :envelope},
      "post /api/v1/threads/:thread_id/report" => {:required_bearer, :thread, :envelope},
      "get /api/v1/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
      "post /api/v1/threads/:thread_id/events" => {:required_bearer, :thread, :envelope},
      "post /api/v1/threads/:thread_id/grants" => {:required_bearer, :thread, :envelope},
      "post /api/v1/memories" => {:required_bearer, :memory, :envelope},
      "get /api/v1/memories" => {:required_bearer, :memory, :envelope},
      "delete /api/v1/memories/:id" => {:required_bearer, :memory, :envelope},
      "get /api/v1/capacity" => {:required_bearer, :capacity, :legacy},
      "post /api/v1/capacity/matches" => {:required_bearer, :capacity, :legacy},
      # The bootstrap read for a box client. It answers a conversation, but it
      # belongs to the box family because that is the only reason it exists:
      # every other box route is addressed by conversation id, and this is
      # where a client that holds only `box:control` learns its own.
      "get /api/v1/conversation" => {:required_bearer, :box, :legacy},
      "get /api/v1/conversations/:conversation_id/boxes" => {:required_bearer, :box, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes" => {:required_bearer, :box, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes/fanout" =>
        {:required_bearer, :box, :legacy},
      "get /api/v1/conversations/:conversation_id/boxes/fanout/:request_id" =>
        {:required_bearer, :box, :legacy},
      "get /api/v1/conversations/:conversation_id/boxes/:box_id" =>
        {:required_bearer, :box, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes/:box_id/commands" =>
        {:required_bearer, :box, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes/:box_id/stop" =>
        {:required_bearer, :box, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes/:box_id/assignments" =>
        {:required_bearer, :assignment, :legacy},
      "get /api/v1/conversations/:conversation_id/boxes/:box_id/assignments/:assignment_id" =>
        {:required_bearer, :assignment, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes/:box_id/assignments/:assignment_id/cancel" =>
        {:required_bearer, :assignment, :legacy},
      "post /api/v1/conversations/:conversation_id/computers/:computer_id/assignments" =>
        {:required_bearer, :assignment, :legacy},
      "get /api/v1/conversations/:conversation_id/computers/:computer_id/assignments/:assignment_id" =>
        {:required_bearer, :assignment, :legacy},
      "post /api/v1/conversations/:conversation_id/computers/:computer_id/assignments/:assignment_id/cancel" =>
        {:required_bearer, :assignment, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes/:box_id/runs" =>
        {:required_bearer, :box, :legacy},
      "get /api/v1/conversations/:conversation_id/boxes/:box_id/runs" =>
        {:required_bearer, :box, :legacy},
      "get /api/v1/conversations/:conversation_id/boxes/:box_id/runs/:run_id" =>
        {:required_bearer, :box, :legacy},
      "get /api/v1/conversations/:conversation_id/boxes/:box_id/runs/:run_id/output" =>
        {:required_bearer, :box, :legacy},
      "post /api/v1/conversations/:conversation_id/boxes/:box_id/runs/:run_id/cancel" =>
        {:required_bearer, :box, :legacy},
      "get /api/v1/computers" => {:required_bearer, :computer, :legacy},
      "patch /api/v1/computers/:id" => {:required_bearer, :computer, :legacy},
      "post /api/v1/computers/:computer_id/probe" => {:required_bearer, :computer, :legacy},
      "post /api/v1/computers/:computer_id/agent-jobs" => {:required_bearer, :computer, :legacy},
      "get /api/v1/computer-agent-jobs/:id" => {:required_bearer, :computer, :legacy},
      "delete /api/v1/computer-agent-jobs/:id" => {:required_bearer, :computer, :legacy},
      "get /api/v1/conversations/:conversation_id/delegation-targets" =>
        {:required_bearer, :delegation, :legacy},
      "post /api/v1/conversations/:conversation_id/delegations" =>
        {:required_bearer, :delegation, :legacy},
      "get /api/v1/conversations/:conversation_id/delegations/:id" =>
        {:required_bearer, :delegation, :legacy},
      "delete /api/v1/conversations/:conversation_id/delegations/:id" =>
        {:required_bearer, :delegation, :legacy},
      "delete /api/v1/repos/:owner/:repo" => {:required_bearer, :repository, :legacy},
      "delete /api/v1/repos/:owner/:repo/issues/:issue_number/assignees" =>
        {:required_bearer, :issue_assignee, :envelope},
      "delete /api/v1/repos/:owner/:repo/issues/:issue_number/labels/:name" =>
        {:required_bearer, :issue_label, :envelope},
      "delete /api/v1/repos/:owner/:repo/issues/comments/:id" =>
        {:required_bearer, :comment, :envelope},
      "delete /api/v1/repos/:owner/:repo/labels/:name" => {:required_bearer, :label, :envelope},
      "delete /api/v1/repos/:owner/:repo/milestones/:milestone_number" =>
        {:required_bearer, :milestone, :envelope},
      "get /api/v1/repository-imports/:id" => {:required_bearer, :repository, :legacy},
      "get /api/v1/user" => {:required_bearer, :repository, :legacy},
      "get /api/v1/user/repos" => {:required_bearer, :repository, :legacy},
      "patch /api/v1/repos/:owner/:repo/issues/:issue_number" =>
        {:required_bearer, :issue, :envelope},
      "patch /api/v1/repos/:owner/:repo" => {:required_bearer, :repository, :legacy},
      "patch /api/v1/repos/:owner/:repo/pulls/:pull_number" =>
        {:required_bearer, :pull_request, :legacy},
      "patch /api/v1/repos/:owner/:repo/issues/comments/:id" =>
        {:required_bearer, :comment, :envelope},
      "patch /api/v1/repos/:owner/:repo/labels/:name" => {:required_bearer, :label, :envelope},
      "patch /api/v1/repos/:owner/:repo/milestones/:milestone_number" =>
        {:required_bearer, :milestone, :envelope},
      "patch /api/v1/repos/:owner/:repo/projectsV2/:project_number/items/:item_id" =>
        {:required_bearer, :project, :envelope},
      "post /api/v1/repos" => {:required_bearer, :repository, :legacy},
      "post /api/v1/orgs/:org/repos" => {:required_bearer, :repository, :legacy},
      "post /api/v1/orgs/:org/repos/imports" => {:required_bearer, :repository, :legacy},
      "post /api/v1/repos/:owner/:repo/pulls" => {:required_bearer, :pull_request, :legacy},
      "put /api/v1/repos/:owner/:repo/pulls/:pull_number/merge-async" =>
        {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks" => {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks/:stack_number/append" =>
        {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks/:stack_number/unstack" =>
        {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks/:stack_number/dissolve" =>
        {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks/:stack_number/rebase" =>
        {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks/:stack_number/merge" =>
        {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks/:stack_number/operations/:operation_id/continue" =>
        {:required_bearer, :stack, :legacy},
      "post /api/v1/repos/:owner/:repo/stacks/:stack_number/operations/:operation_id/abort" =>
        {:required_bearer, :stack, :legacy},
      # pipe_through :fleet_promotion_api — operator-only fleet promotion. The
      # bearer must carry `deployments:promote` and its account must be a
      # current operator, so no tenant credential reaches these three routes.
      "post /api/v1/admin/forge/targets" => {:required_bearer, :fleet_target, :envelope},
      "get /api/v1/admin/forge/targets" => {:required_bearer, :fleet_target, :envelope},
      "get /api/v1/admin/forge/targets/:id" => {:required_bearer, :fleet_target, :envelope},
      # pipe_through :deployments_api — tenant deployment authority only. No
      # route here is anonymous, and none of them reaches the operator fleet
      # promotion surface.
      "get /api/v1/repos/:owner/:repo/deployment-environments" =>
        {:required_bearer, :deployment, :legacy},
      "put /api/v1/repos/:owner/:repo/deployment-environments/:name" =>
        {:required_bearer, :deployment, :legacy},
      "get /api/v1/repos/:owner/:repo/deployment-environments/:name/protection" =>
        {:required_bearer, :deployment, :legacy},
      "post /api/v1/repos/:owner/:repo/deployments" => {:required_bearer, :deployment, :legacy},
      "get /api/v1/repos/:owner/:repo/deployments" => {:required_bearer, :deployment, :legacy},
      "get /api/v1/repos/:owner/:repo/deployments/:id" =>
        {:required_bearer, :deployment, :legacy},
      "post /api/v1/repos/:owner/:repo/deployments/:id/cancel" =>
        {:required_bearer, :deployment, :legacy},
      "post /api/v1/repos/:owner/:repo/deployments/:id/approvals" =>
        {:required_bearer, :deployment, :legacy},
      "get /api/v1/repos/:owner/:repo/deployments/:id/approvals" =>
        {:required_bearer, :deployment, :legacy},
      "get /api/v1/repos/:owner/:repo/deployments/:id/events" =>
        {:required_bearer, :deployment, :legacy},
      "post /api/v1/repos/:owner/:repo/deployment-checks" =>
        {:required_bearer, :deployment, :legacy},
      "post /api/v1/repos/:owner/:repo/deployment-workflow-grants" =>
        {:required_bearer, :deployment, :legacy},
      "delete /api/v1/repos/:owner/:repo/deployment-workflow-grants/:id" =>
        {:required_bearer, :deployment, :legacy},
      # Dual-principal participation writes accept either a human forge token or
      # an agent participation credential.
      "post /api/v1/forum/topics" => {:required_bearer, :forum, :legacy},
      "post /api/v1/forum/topics/:topic_id/posts" => {:required_bearer, :forum, :legacy},
      "post /api/v1/repos/:owner/:repo/issues" => {:required_bearer, :issue, :envelope},
      "post /api/v1/repos/:owner/:repo/issues/capture" => {:required_bearer, :issue, :envelope},
      "post /api/v1/repos/:owner/:repo/issues/:issue_number/comments" =>
        {:required_bearer, :comment, :envelope},
      "post /api/v1/repos/:owner/:repo/issues/:issue_number/completion_claim" =>
        {:required_bearer, :issue, :envelope},
      "get /api/v1/agent" => {:required_bearer, :agent, :legacy},
      "post /api/v1/agents/:handle/box-control" => {:required_bearer, :agent, :legacy},
      "post /api/v1/gym/runs" => {:required_bearer, :gym, :envelope},
      "post /api/v1/gym/runs/start" => {:required_bearer, :gym, :envelope},
      "post /api/v1/gym/runs/:id/trials" => {:required_bearer, :gym, :envelope},
      "patch /api/v1/gym/runs/:id" => {:required_bearer, :gym, :envelope},
      "get /api/v1/gym/runs" => {:required_bearer, :gym, :envelope},
      "delete /api/v1/agents/:handle/box-control" => {:required_bearer, :agent, :legacy},
      "post /api/v1/agents/:handle/computer-control" => {:required_bearer, :agent, :legacy},
      "delete /api/v1/agents/:handle/computer-control" => {:required_bearer, :agent, :legacy},
      "post /api/v1/agent/credentials" => {:required_bearer, :agent, :legacy},
      "post /api/v1/agent/links" => {:required_bearer, :agent, :legacy},
      "get /api/v1/agents/links" => {:required_bearer, :agent, :legacy},
      "post /api/v1/agents/links/:id/accept" => {:required_bearer, :agent, :legacy},
      "post /api/v1/agents/links/:id/reject" => {:required_bearer, :agent, :legacy},
      "delete /api/v1/agents/links/:id" => {:required_bearer, :agent, :legacy},
      "post /api/v1/forum/claims" => {:required_bearer, :forum, :legacy},
      "get /api/v1/forum/claims" => {:required_bearer, :forum, :legacy},
      "post /api/v1/reputation/subject-claims" => {:required_bearer, :reputation, :legacy},
      "get /api/v1/reputation/subject-claims" => {:required_bearer, :reputation, :legacy},
      "get /api/v1/reputation/subject-claims/pending" => {:required_bearer, :reputation, :legacy},
      "patch /api/v1/reputation/subject-claims/:id" => {:required_bearer, :reputation, :legacy},
      # Moderation and claim review: a bearer the controller then checks for
      # operator authority.
      "patch /api/v1/forum/topics/:id" => {:required_bearer, :forum, :legacy},
      "patch /api/v1/forum/posts/:id" => {:required_bearer, :forum, :legacy},
      "get /api/v1/forum/claims/pending" => {:required_bearer, :forum, :legacy},
      "patch /api/v1/forum/claims/:id" => {:required_bearer, :forum, :legacy},
      # Tips: a destination, a payment, and a settlement history each belong to
      # one account, so anonymous callers never reach them.
      "post /api/v1/forum/tips/destination" => {:required_bearer, :forum, :legacy},
      "patch /api/v1/forum/tips/destination" => {:required_bearer, :forum, :legacy},
      "get /api/v1/forum/tips/destination" => {:required_bearer, :forum, :legacy},
      "get /api/v1/forum/tips/received" => {:required_bearer, :forum, :legacy},
      "post /api/v1/forum/posts/:post_id/tips" => {:required_bearer, :forum, :legacy},
      "post /api/v1/repos/:owner/:repo/issues/:issue_number/assignees" =>
        {:required_bearer, :issue_assignee, :envelope},
      "post /api/v1/repos/:owner/:repo/issues/:issue_number/dependencies" =>
        {:required_bearer, :issue_dependency, :envelope},
      "delete /api/v1/repos/:owner/:repo/issues/:issue_number/dependencies/:blocked_by_number" =>
        {:required_bearer, :issue_dependency, :envelope},
      "post /api/v1/repos/:owner/:repo/issues/:issue_number/labels" =>
        {:required_bearer, :issue_label, :envelope},
      "post /api/v1/repos/:owner/:repo/labels" => {:required_bearer, :label, :envelope},
      "post /api/v1/repos/:owner/:repo/milestones" => {:required_bearer, :milestone, :envelope},
      "post /api/v1/repos/:owner/:repo/projectsV2" => {:required_bearer, :project, :envelope},
      "post /api/v1/repos/:owner/:repo/projectsV2/:project_number/items" =>
        {:required_bearer, :project, :envelope},
      "post /api/v1/repos/:owner/:repo/projectsV2/:project_number/items/:item_id/move" =>
        {:required_bearer, :project, :envelope},
      "delete /api/v1/repos/:owner/:repo/projectsV2/:project_number/items/:item_id" =>
        {:required_bearer, :project, :envelope},
      "post /api/v1/repos/:owner/:repo/projectsV2/:project_number/fields" =>
        {:required_bearer, :project, :envelope},
      "patch /api/v1/repos/:owner/:repo/projectsV2/:project_number" =>
        {:required_bearer, :project, :envelope},
      "post /api/v1/repos/:owner/:repo/projectsV2/:project_number/notes" =>
        {:required_bearer, :project, :envelope},
      "patch /api/v1/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id" =>
        {:required_bearer, :project, :envelope},
      "delete /api/v1/repos/:owner/:repo/projectsV2/:project_number/notes/:note_id" =>
        {:required_bearer, :project, :envelope},
      "patch /api/v1/repos/:owner/:repo/projectsV2/:project_number/fields/:field_id" =>
        {:required_bearer, :project, :envelope},
      "delete /api/v1/repos/:owner/:repo/projectsV2/:project_number/fields/:field_id" =>
        {:required_bearer, :project, :envelope},
      "delete /api/v1/repos/:owner/:repo/projectsV2/:project_number" =>
        {:required_bearer, :project, :envelope},
      "post /api/v1/user/repos" => {:required_bearer, :repository, :legacy},
      "post /api/v1/user/repos/imports" => {:required_bearer, :repository, :legacy},
      "put /api/v1/repos/:owner/:repo/issues/:issue_number" =>
        {:required_bearer, :issue, :envelope},
      "put /api/v1/repos/:owner/:repo/issues/comments/:id" =>
        {:required_bearer, :comment, :envelope},
      "put /api/v1/repos/:owner/:repo/labels/:name" => {:required_bearer, :label, :envelope},
      "put /api/v1/repos/:owner/:repo/milestones/:milestone_number" =>
        {:required_bearer, :milestone, :envelope},
      # Landed under the old prefix while the rename was in flight. It moves
      # with everything else; the alias answers the prefix it was written
      # against until that alias goes away.
      "post /api/v1/traces" => {:required_bearer, :trace, :envelope}
    }
  end
end