From b4e427aac6d60e54f9bf61658a5438b89f0bb48a Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 30 Sep 2026 14:57:05 -0600 Subject: [PATCH 1/2] Adds a library hold at its own BasicHTTP location The reference host now shows statifier_router's durable BasicHTTP front in the library world. StatifierExamples.HoldDesk is a router configuration of its own that sets :basichttp, so each hold it creates gets a location; the parcel configuration sets none. A hold tells the branch desk it was placed through the processor, planned and performed in the host's executor, with its location as reply_to, and the desk's copy.shelved POST at that location reaches BasicHTTPController, which hands it to StatifierRouter.BasicHTTP.Front and answers response/1. A send the desk refuses re-enters as error.communication and the hold ends desk_unreached. A new migration creates the location table with up_locations/1. The router moves to ~> 0.9.2, the first release whose migrations and address reaper run on SQLite, statifier to ~> 2.10, and mix.lock takes statifier_persistence 0.24.0 with them; the guides' pin tables follow. Refs: se-jg43 --- README.md | 3 + config/test.exs | 5 + docs/guides/basichttp-front.md | 131 ++++++++++ docs/guides/first-workflow-routed.md | 30 ++- docs/guides/first-workflow.md | 13 +- docs/guides/migrating-waiting-executions.md | 2 +- lib/statifier_examples/hold_desk.ex | 227 ++++++++++++++++++ .../controllers/basic_http_controller.ex | 59 +++++ lib/statifier_examples_web/endpoint.ex | 1 + lib/statifier_examples_web/raw_body.ex | 28 +++ lib/statifier_examples_web/router.ex | 7 + mix.exs | 35 ++- mix.lock | 6 +- priv/library/hold_desk.scxml | 23 ++ ...0120001_add_statifier_router_locations.exs | 23 ++ .../first_workflow_test.exs | 2 +- .../migrate_waiting_test.exs | 2 +- test/statifier_examples/mix_deps_test.exs | 35 ++- .../routed_workflow_test.exs | 2 +- .../basic_http_controller_test.exs | 150 ++++++++++++ test/support/desk_transport.ex | 17 ++ 21 files changed, 778 insertions(+), 23 deletions(-) create mode 100644 docs/guides/basichttp-front.md create mode 100644 lib/statifier_examples/hold_desk.ex create mode 100644 lib/statifier_examples_web/controllers/basic_http_controller.ex create mode 100644 lib/statifier_examples_web/raw_body.ex create mode 100644 priv/library/hold_desk.scxml create mode 100644 priv/repo/migrations/20260930120001_add_statifier_router_locations.exs create mode 100644 test/statifier_examples_web/controllers/basic_http_controller_test.exs create mode 100644 test/support/desk_transport.ex diff --git a/README.md b/README.md index 49766eb..bdc29e9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ execution its key names and handing the finished execution's answer to a sink. [`docs/guides/migrating-waiting-executions.md`](docs/guides/migrating-waiting-executions.md) moves waiting library loans onto a new revision of their document and back, and `mix statifier_examples.migrate_waiting` runs it. +[`docs/guides/basichttp-front.md`](docs/guides/basichttp-front.md) gives a +library hold its own HTTP location through `statifier_router`'s BasicHTTP +front, and answers the branch desk's POST at it. ## Opening a document in the editor diff --git a/config/test.exs b/config/test.exs index 88ad032..b7a7f92 100644 --- a/config/test.exs +++ b/config/test.exs @@ -47,3 +47,8 @@ config :phoenix_live_view, # Sort query params output of verified routes for robust url comparisons config :phoenix, sort_verified_routes_query_params: true + +# The hold desk's outbound BasicHTTP POSTs go to a transport that hands +# each one to the process that made it, so a test reads what the desk was +# sent instead of reaching a branch desk over the network. +config :statifier_examples, StatifierExamples.HoldDesk, transport: StatifierExamples.DeskTransport diff --git a/docs/guides/basichttp-front.md b/docs/guides/basichttp-front.md new file mode 100644 index 0000000..4b4cf92 --- /dev/null +++ b/docs/guides/basichttp-front.md @@ -0,0 +1,131 @@ +# A durable execution with an HTTP location: the BasicHTTP front + +A patron places a hold on a copy at the Riverside branch. The hold is a +durable execution, and it needs to hear back from the branch desk when the +copy is on the holds shelf. With `statifier_router`'s BasicHTTP front, the +execution gets an HTTP location of its own - a URL the desk POSTs an event +to - and the router delivers that POST into the execution through the same +path every routed event takes. + +This guide walks the pieces as this app wires them: +`StatifierExamples.HoldDesk` (the router configuration, the chart's +resolver and the executor), `priv/library/hold_desk.scxml` (the chart), +`StatifierExamplesWeb.BasicHTTPController` (the front's action) and +`test/statifier_examples_web/controllers/basic_http_controller_test.exs`, +which drives the whole of it through the controller. + +## A location is a bearer capability + +Anyone who holds a location can post events to that execution, and the +router authenticates nothing beyond possession of it (ruled by the +operator, 2026-09-30). The hold hands its location to the one desk its +request names and to nobody else, and nothing here logs it. A host serves +the base URL over TLS and rotates a location that may have leaked with +`StatifierRouter.BasicHTTP.rotate_location/2`, after which the old one +answers 404. + +## The pins + +| Package | Version | What this guide uses it for | +|---|---|---| +| `statifier_router` | 0.9.2 | the `:basichttp` key, the location, `StatifierRouter.BasicHTTP.Front`, the location table | +| `statifier` | 2.10.0 | the Basic HTTP Event I/O Processor and its decoder | +| `statifier_persistence` | 0.24.0 | the chart registry, the execution, the input log | + +0.9.2 is the floor because it is the first `statifier_router` release +whose migrations and address reaper both run on this app's SQLite +database. + +## The chart + +`priv/library/hold_desk.scxml` waits in `requested` for the hold request. +On `hold.requested` it sends `hold.placed` to the desk the request names, +with a `` whose `targetexpr` is the desk's URL, and +passes three parameters: the hold, the copy, and `reply_to`, its own +location, read from `_ioprocessors['basichttp']['location']`. It then waits +in `waiting` for `copy.shelved` and finishes in `shelved`. + +## The configuration + +`StatifierExamples.HoldDesk.config/0` is a router configuration of its own: +one binding, `hold_requests`, that routes each hold request to the +execution keyed by its hold id, and the `:basichttp` key: + +```elixir +basichttp: [base_url: StatifierExamplesWeb.Endpoint.url() <> "/basichttp"] +``` + +Setting the key does two things. It registers the router's processor under +the processor's URI and its short form `basichttp`, which is what lets the +chart's `` pass the engine's type check. And it +gives every execution created under a new address row a location: the base +URL, `/`, and a 43-character token the router mints, never the execution +id. The parcel recipe's configuration, +`StatifierExamples.RoutedWorkflow.config/0`, does not set the key, so its +executions get no location. + +The location token lives in the router's location table, which only a +configuration with `:basichttp` needs. This app creates it in +`priv/repo/migrations/20260930120001_add_statifier_router_locations.exs`: + +```elixir +def up, do: StatifierRouter.Migrations.up_locations(@opts) +def down, do: StatifierRouter.Migrations.down_locations(@opts) +``` + +with the same `depot_id` leading column the app's first router migration +gives every router table. + +## The outbound send runs in the executor + +A durable execution has no session to perform its sends: the step hands +each effect to the configuration's executor. `StatifierExamples.HoldDesk.execute/2` +plans a BasicHTTP send with `StatifierRouter.BasicHTTP.deliver/3` and +performs what it planned with the processor's `perform/2`, which POSTs a +form body to the desk with the send's `scxml-send-key` header. The POST +goes through the configuration's `:transport`: statifier's default, +on OTP's `:httpc`, in the dev app, and a transport under test that hands +the POST back to the test instead of sending it. + +The POST is made inside the delivery's transaction, before the step +commits. A desk that does not answer 2xx, or does not answer at all, is a +failed send: `statifier_persistence` enters `error.communication`, +carrying the send id, into the execution in the same step, and the chart +takes it from `waiting` to its other final state, `desk_unreached`. A +delayed BasicHTTP send is refused the same way, because its timer would +live in the delivering process rather than in the database. + +## The front + +The router answers `/basichttp/:token` for every method with +`StatifierExamplesWeb.BasicHTTPController.event/2`, outside the browser +pipeline. The action builds the request map the front reads - the token, +the method, the content type, the body as it arrived, the query string and +the `scxml-send-key` header - hands it to +`StatifierRouter.BasicHTTP.Front.handle/3` and answers the status +`StatifierRouter.BasicHTTP.Front.response/1` maps the answer to: + +| The request | The answer | +|---|---| +| a POST the execution takes, or a repeat of a send key it already took | 204 | +| a POST at a location that reaches no execution: unknown, rotated away, or finished | 404 | +| any other method | 405, with `allow: POST` | +| a body or a send key the decoder refuses | 400 | + +A form body is read by `Plug.Parsers` before any action runs, so the +endpoint's parsers use `StatifierExamplesWeb.RawBody`, which keeps the raw +body of a request under `/basichttp` for the action to hand on. + +## Driving it + +The controller test routes a hold request, reads the `hold.placed` POST the +desk was sent, takes `reply_to` from it and POSTs +`_scxmleventname=copy.shelved` at that location through the endpoint: + +```elixir +post(conn, "/basichttp/" <> token, "_scxmleventname=copy.shelved") +``` + +The answer is 204 and the execution is completed; the same POST again is +404, because the hold has finished. In the dev app, a desk that holds the +location does the same with any HTTP client. diff --git a/docs/guides/first-workflow-routed.md b/docs/guides/first-workflow-routed.md index fd925f4..f744426 100644 --- a/docs/guides/first-workflow-routed.md +++ b/docs/guides/first-workflow-routed.md @@ -23,17 +23,17 @@ The recipe is written and checked against these releases, which are what | Package | Version | What the recipe uses it for | |---|---|---| -| `statifier_router` | 0.6.0 | the binding, the delivery, the address and dedupe tables, the route, the reapers, the migration's `:leading_columns`, `:on_create` and `:on_step` | -| `statifier_persistence` | 0.21.0 | the chart registry, the execution, the input log, `ended_at` | +| `statifier_router` | 0.9.2 | the binding, the delivery, the address and dedupe tables, the route, the reapers, the migration's `:leading_columns`, `:on_create` and `:on_step` | +| `statifier_persistence` | 0.24.0 | the chart registry, the execution, the input log, `ended_at` | | `statifier_blocks` | 0.41.0 | the document and the compile | -| `statifier` | 2.9.0 | compiling the chart and running it | +| `statifier` | 2.10.0 | compiling the chart and running it | -`statifier_router` 0.6.0 requires `statifier ~> 2.9` and +`statifier_router` 0.9.2 requires `statifier ~> 2.10` and `statifier_persistence ~> 0.18`, which is what moved those two with it. `mix.exs` asks for `statifier_persistence ~> 0.20` all the same: 0.20.0 adds `Executions.migrate_batch/3`, which `docs/guides/migrating-waiting-executions.md` walks, and `mix.lock` -resolves 0.21.0, the release this table names. The jobs run on this +resolves 0.24.0, the release this table names. The jobs run on this app's own Oban. The first line the command prints names the versions it actually loaded. @@ -341,8 +341,26 @@ Each of these is in `statifier_router`'s README and not needed here: ## Moving the first-workflow host to these pins +The latest move, from statifier_router 0.6.0 to 0.9.2, is the one that +touched a migration and the reapers. 0.8.0 added the router's third migration version, +V03, which renames the subscription table's unique index on Postgres. The +migration this recipe runs calls `StatifierRouter.Migrations.up/1` with no +version, so on a fresh database it runs V03 too, and V03's `ALTER INDEX` +failed on SQLite until 0.9.1 made it do nothing there; a database that +already ran V02 needs no new migration of its own, since V03 has nothing +to rename on SQLite. 0.8.0 also wrote the address reaper's stamp and +delete with Postgres's `= ANY(...)`, so the `reaped` step failed on SQLite +until 0.9.2 wrote them as an IN list. 0.9.2 is therefore the floor. The same move took statifier +to 2.10.0 and statifier_persistence to 0.24.0, which changed nothing here. +0.9.0's BasicHTTP front is not part of this recipe; its configuration sets +no `:basichttp`, so its executions get no location. +`docs/guides/basichttp-front.md` walks the front. + +The move before it, to the first pins this recipe was written against: + This app moved from statifier 2.8.1, statifier_persistence 0.17.0 and -statifier_router 0.4.1 to the pins above, and nothing in it changed but the +statifier_router 0.4.1 to statifier 2.9.0, statifier_persistence 0.19.0 and +statifier_router 0.6.0, and nothing in it changed but the requirements: - **statifier 2.9.0** adds `Statifier.Publish.findings/2` and diff --git a/docs/guides/first-workflow.md b/docs/guides/first-workflow.md index 006af4d..113b943 100644 --- a/docs/guides/first-workflow.md +++ b/docs/guides/first-workflow.md @@ -19,11 +19,11 @@ The recipe is written and checked against these releases, which are what | Package | Version | What the recipe uses it for | |---|---|---| -| `statifier` | 2.9.0 | compiling the chart and running it | +| `statifier` | 2.10.0 | compiling the chart and running it | | `statifier_blocks` | 0.41.0 | the document, `Plan.expressible/3`, the compile | -| `statifier_persistence` | 0.21.0 | the chart registry, the execution, the input log, `ended_at` | +| `statifier_persistence` | 0.24.0 | the chart registry, the execution, the input log, `ended_at` | | `statifier_oban` | 0.13.0 | the invoke job and its `:invoke_timeout`, the timer job | -| `statifier_router` | 0.6.0 | two of the publish-time checks | +| `statifier_router` | 0.9.2 | two of the publish-time checks | `statifier_datamodel` 0.5.0 arrives through `statifier_blocks`. The first line the command prints names the versions it actually loaded. @@ -237,6 +237,13 @@ The move after it, to `statifier_persistence ~> 0.20` with `mix.lock` at nothing in this recipe calls back into the execution it is stepping, which is the one thing 0.21.0 refuses that it used to take. +The move to statifier 2.10.0, statifier_router 0.9.2 and, with them, +statifier_persistence 0.24.0 needed nothing in this recipe either. The +router releases add a migration version and the BasicHTTP front, which +`docs/guides/basichttp-front.md` walks; none of the persistence releases +adds a migration, and none of what they refuse is something this recipe +does. + The statifier_blocks moves after 0.35.0, one minor at a time to 0.41.0, needed nothing in this recipe either. statifier_blocks' `docs/upgrading.md` says what each asks of a host; this recipe calls `Decode.decode/1`, diff --git a/docs/guides/migrating-waiting-executions.md b/docs/guides/migrating-waiting-executions.md index d7a9f1e..973e3d8 100644 --- a/docs/guides/migrating-waiting-executions.md +++ b/docs/guides/migrating-waiting-executions.md @@ -34,7 +34,7 @@ opened. The next run starts from an empty chart. The migration surface is `statifier_persistence`'s. `mix.exs` asks for `~> 0.20`, the release that adds `Executions.migrate_batch/3`, and -`mix.lock` resolves 0.21.0. The first line the command prints names the +`mix.lock` resolves 0.24.0. The first line the command prints names the version it loaded. The migration surface is the verb and its report. A dry run is the diff --git a/lib/statifier_examples/hold_desk.ex b/lib/statifier_examples/hold_desk.ex new file mode 100644 index 0000000..8f83796 --- /dev/null +++ b/lib/statifier_examples/hold_desk.ex @@ -0,0 +1,227 @@ +defmodule StatifierExamples.HoldDesk do + @moduledoc """ + A durable execution with an HTTP location of its own, in the library + world: a patron's hold on a copy, at one branch's desk, taking its + events through `statifier_router`'s BasicHTTP front. + + The chart is `priv/library/hold_desk.scxml`. One binding routes a hold + request into a new execution keyed by the hold id. The execution tells + the branch desk the hold was placed with a ``, + handing the desk its own location as `reply_to`, and waits; the desk + POSTs `copy.shelved` at that location once the copy is on the holds + shelf, and the execution finishes. The POST reaches + `StatifierExamplesWeb.BasicHTTPController`, which hands it to + `StatifierRouter.BasicHTTP.Front`. + + **A location is a bearer capability** (ruled by the operator, + 2026-09-30): anyone who holds it can post events to that execution, and + the router authenticates nothing beyond possession of it. This module + hands the location to the desk the hold request names and to nobody + else, and never logs it. + + `config/0` is a router configuration of its own, separate from + `StatifierExamples.RoutedWorkflow.config/0`: only this configuration + sets `:basichttp`, so only the executions it creates get a location. + + ## The outbound half runs in this host's executor + + The router registers `StatifierRouter.BasicHTTP` for both of the + processor's type strings, which is what lets the chart's `` pass + the engine's type check and read `_ioprocessors['basichttp']`. A + durable execution has no session to perform the send, so the effect + reaches `execute/2`, which plans it with the router's processor and + performs what it planned, through the configuration's transport. That + runs inside the delivery's transaction: one POST to the desk, made + before the step commits. A POST the desk does not answer with a 2xx is + a failed send, which `statifier_persistence` enters into the execution + as `error.communication`, and the chart ends the hold unreached. A + delayed BasicHTTP send, whose timer would live in this process rather + than in the database, is refused, and enters the execution the same + way. + """ + + @behaviour StatifierRouter.Resolver + + alias Statifier.Effect.{Send, SendDelayed} + alias Statifier.Machine + alias Statifier.Send.Event, as: SendEvent + alias StatifierExamples.FirstWorkflow + alias StatifierExamples.RoutedWorkflow.Stepper + alias StatifierPersistence.Storage + alias StatifierRouter.{BasicHTTP, Config} + + @chart "library/hold_desk.scxml" + + # The document id the binding and the resolver know the chart by. + @document_id "hold_desk" + + @binding_id "hold_requests" + @source "hold_requests" + + # The processor's URI and its short form: the two type strings the + # router registers `StatifierRouter.BasicHTTP` under. + @basichttp_types ["http://www.w3.org/TR/scxml/#BasicHTTPEventProcessor", "basichttp"] + + # The path the controller answers at, under the endpoint's URL. + @front_path "/basichttp" + + @doc """ + The router configuration hold requests are routed with. + + One binding, `#{@binding_id}`, routes every hold request from the + `#{@source}` source to the execution of `#{@document_id}` keyed by its + `hold_id`. `:basichttp` names the base URL the controller answers at + (`base_url/0`) and the transport outbound POSTs go through + (`:transport` under this module's application environment, statifier's + default when unset). Creates and steps go through + `StatifierExamples.RoutedWorkflow.Stepper`, for this app's SQLite + serialization. + """ + @spec config() :: Config.t() + def config do + case Config.new( + repo: StatifierExamples.Repo, + store: FirstWorkflow.store(), + executor: &execute/2, + resolver: __MODULE__, + chart_resolver: &chart/1, + on_create: Stepper, + on_step: Stepper, + bindings: [hold_binding()], + basichttp: basichttp() + ) do + {:ok, config} -> config + {:error, reason} -> raise "the hold desk's router configuration: #{inspect(reason)}" + end + end + + @doc "The base URL every location starts with: the endpoint's URL and `#{@front_path}`." + @spec base_url() :: String.t() + def base_url, do: StatifierExamplesWeb.Endpoint.url() <> @front_path + + @doc "The document id the binding and the resolver share." + @spec document_id() :: String.t() + def document_id, do: @document_id + + @doc """ + Compiles the chart and stores it under its content hash, so the + resolver answers it for `#{@document_id}`. Answers the content hash. + """ + @spec register() :: {:ok, String.t()} | {:error, term()} + def register do + with {:ok, machine, scxml} <- compile(), + :ok <- Storage.save_chart(FirstWorkflow.store(), machine, scxml) do + {:ok, Machine.identity(machine).content_hash} + end + end + + @doc """ + Routes one hold request: `hold` carries `"hold_id"`, `"copy_id"` and + `"desk"`, the URL of the branch desk the execution tells, in `scope`. + """ + @spec request(String.t(), map()) :: {:ok, [StatifierRouter.outcome()]} | {:error, term()} + def request(scope, %{"hold_id" => hold_id} = hold) do + StatifierRouter.route(config(), %{ + scope: scope, + source: @source, + message_id: "hold-requested/" <> hold_id, + data: Map.put(hold, "kind", "hold") + }) + end + + # ------------------------------------------------------- the resolver + + @impl StatifierRouter.Resolver + @spec resolve(String.t(), String.t()) :: StatifierRouter.Resolver.result() + def resolve(_scope, @document_id) do + with {:ok, machine, _scxml} <- compile(), + content_hash = Machine.identity(machine).content_hash, + {:ok, _chart} <- Storage.fetch_chart(FirstWorkflow.store(), content_hash) do + {content_hash, machine} + else + _unregistered -> {:error, :not_published} + end + end + + def resolve(_scope, _document), do: {:error, :not_published} + + @doc "The chart registered under `content_hash`, compiled, or `:error`." + @spec chart(String.t()) :: {:ok, Machine.t()} | :error + def chart(content_hash) do + with {:ok, chart} <- Storage.fetch_chart(FirstWorkflow.store(), content_hash), + {:ok, machine} <- Statifier.compile(chart.chart_blob) do + {:ok, machine} + else + _missing -> :error + end + end + + # ------------------------------------------------------- the executor + + @doc """ + The executor every create and step hands its effects to. A BasicHTTP + `` is planned with `StatifierRouter.BasicHTTP.deliver/3` and each + instruction it plans is performed with the processor's `perform/2`; a + delayed one is refused as `{:delayed_basichttp_send, send_id}`. Every + other effect is passed. + """ + @spec execute(Statifier.Effect.t(), StatifierPersistence.Executor.context()) :: + :ok | {:error, term()} + def execute({:send, %Send{type: type} = send}, %{execution_id: execution_id}) + when type in @basichttp_types do + ctx = %{session_id: execution_id, opts: basichttp()} + event = SendEvent.build(send, execution_id) + {:ok, instructions} = BasicHTTP.deliver(send, event, ctx) + perform(instructions, ctx, send) + end + + def execute({:send_delayed, %SendDelayed{type: type} = send}, _context) + when type in @basichttp_types, + do: {:error, {:delayed_basichttp_send, send.send_id}} + + def execute(_effect, _context), do: :ok + + @spec perform([term()], map(), Send.t()) :: :ok | {:error, term()} + defp perform(instructions, ctx, send) do + Enum.reduce_while(instructions, :ok, fn + {:handler, module, payload}, :ok -> + case module.perform(payload, ctx) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + + _unperformed, :ok -> + {:halt, {:error, {:basichttp_send_not_planned, send.send_id}}} + end) + end + + # ---------------------------------------------------------- the parts + + @spec basichttp() :: keyword() + defp basichttp do + case Keyword.fetch(Application.get_env(:statifier_examples, __MODULE__, []), :transport) do + {:ok, transport} -> [base_url: base_url(), transport: transport] + :error -> [base_url: base_url()] + end + end + + @spec hold_binding() :: map() + defp hold_binding do + %{ + id: @binding_id, + source: @source, + match: ~s(event.kind == "hold"), + key: "event.hold_id", + document: @document_id, + event: "hold.requested", + data: ["hold_id", "copy_id", "desk"] + } + end + + @spec compile() :: {:ok, Machine.t(), String.t()} | {:error, term()} + defp compile do + scxml = :statifier_examples |> :code.priv_dir() |> Path.join(@chart) |> File.read!() + + with {:ok, machine} <- Statifier.compile(scxml), do: {:ok, machine, scxml} + end +end diff --git a/lib/statifier_examples_web/controllers/basic_http_controller.ex b/lib/statifier_examples_web/controllers/basic_http_controller.ex new file mode 100644 index 0000000..ba9acd5 --- /dev/null +++ b/lib/statifier_examples_web/controllers/basic_http_controller.ex @@ -0,0 +1,59 @@ +defmodule StatifierExamplesWeb.BasicHTTPController do + @moduledoc """ + The BasicHTTP front for `StatifierExamples.HoldDesk`'s durable + executions: every request at `/basichttp/:token` is handed to + `StatifierRouter.BasicHTTP.Front.handle/3`, and answered with the + status and headers `StatifierRouter.BasicHTTP.Front.response/1` maps + its answer to - 204 for a delivered event or a duplicate, 404 for a + location that reaches no execution, 405 with `allow: POST` for another + method, 400 for a body the decoder refuses. + + The token is a bearer capability (ruled by the operator, 2026-09-30): + holding it is the whole of the authorization, so this action checks + nothing else and never logs it. + """ + + use StatifierExamplesWeb, :controller + + alias StatifierExamples.HoldDesk + alias StatifierRouter.BasicHTTP.Front + + @doc "Hands one request at a location to the front and answers its status." + @spec event(Plug.Conn.t(), map()) :: Plug.Conn.t() + def event(conn, _params) do + {body, conn} = body(conn) + + answer = + Front.handle(HoldDesk.config(), %{ + token: conn.path_params["token"], + method: conn.method, + content_type: header(conn, "content-type"), + body: body, + query: if(conn.query_string == "", do: nil, else: conn.query_string), + send_key: header(conn, "scxml-send-key") + }) + + {status, headers} = Front.response(answer) + + conn + |> merge_resp_headers(headers) + |> send_resp(status, "") + end + + # The body `StatifierExamplesWeb.RawBody` kept when `Plug.Parsers` read + # it, or the body as it is still waiting, for a content type the parsers + # passed. + @spec body(Plug.Conn.t()) :: {binary(), Plug.Conn.t()} + defp body(%Plug.Conn{private: %{raw_body: body}} = conn), do: {body, conn} + + defp body(conn) do + case read_body(conn) do + {:ok, body, conn} -> {body, conn} + {_more_or_error, _partial, conn} -> {"", conn} + {:error, _reason} -> {"", conn} + end + end + + @spec header(Plug.Conn.t(), String.t()) :: String.t() | nil + defp header(conn, name), do: conn |> get_req_header(name) |> List.first() +end diff --git a/lib/statifier_examples_web/endpoint.ex b/lib/statifier_examples_web/endpoint.ex index 3dec257..f774668 100644 --- a/lib/statifier_examples_web/endpoint.ex +++ b/lib/statifier_examples_web/endpoint.ex @@ -41,6 +41,7 @@ defmodule StatifierExamplesWeb.Endpoint do plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], + body_reader: {StatifierExamplesWeb.RawBody, :read_body, []}, json_decoder: Phoenix.json_library() plug Plug.MethodOverride diff --git a/lib/statifier_examples_web/raw_body.ex b/lib/statifier_examples_web/raw_body.ex new file mode 100644 index 0000000..95b9db7 --- /dev/null +++ b/lib/statifier_examples_web/raw_body.ex @@ -0,0 +1,28 @@ +defmodule StatifierExamplesWeb.RawBody do + @moduledoc """ + The body reader `Plug.Parsers` uses in `StatifierExamplesWeb.Endpoint`, + which keeps the raw body of a request under `/basichttp` in + `conn.private[:raw_body]`. + + A BasicHTTP POST is usually a form body, which `Plug.Parsers` reads and + decodes before any controller runs, and `StatifierRouter.BasicHTTP.Front` + needs the body as it arrived. Every other path reads as before. + """ + + @doc "Reads the body as `Plug.Conn.read_body/2` does, keeping it on a `/basichttp` path." + @spec read_body(Plug.Conn.t(), keyword()) :: + {:ok, binary(), Plug.Conn.t()} | {:more, binary(), Plug.Conn.t()} | {:error, term()} + def read_body(%Plug.Conn{path_info: ["basichttp" | _]} = conn, opts) do + case Plug.Conn.read_body(conn, opts) do + {:ok, body, conn} -> {:ok, body, keep(conn, body)} + {:more, body, conn} -> {:more, body, keep(conn, body)} + other -> other + end + end + + def read_body(conn, opts), do: Plug.Conn.read_body(conn, opts) + + defp keep(conn, body) do + Plug.Conn.put_private(conn, :raw_body, (conn.private[:raw_body] || "") <> body) + end +end diff --git a/lib/statifier_examples_web/router.ex b/lib/statifier_examples_web/router.ex index 3fbd58a..312e3f5 100644 --- a/lib/statifier_examples_web/router.ex +++ b/lib/statifier_examples_web/router.ex @@ -24,6 +24,13 @@ defmodule StatifierExamplesWeb.Router do live "/signup-journey", SignupJourneyLive end + # The BasicHTTP front: a location of a durable execution. No pipeline: + # the request is a machine's POST, not a browser's, and every method + # reaches the action so the front can answer 405 itself. + scope "/basichttp", StatifierExamplesWeb do + match :*, "/:token", BasicHTTPController, :event + end + # Other scopes may use custom stacks. # scope "/api", StatifierExamplesWeb do # pipe_through :api diff --git a/mix.exs b/mix.exs index 345abb8..da3c536 100644 --- a/mix.exs +++ b/mix.exs @@ -123,7 +123,14 @@ defmodule StatifierExamples.MixProject do # 2.9.0 is REQUIRED: `statifier_router` 0.6.0, below, states # `{:statifier, "~> 2.9"}`. 2.9.0 adds `Statifier.Publish.findings/2` # and `MachineState.last_selection`, and this app reads neither. - {:statifier, "~> 2.9"}, + # + # 2026-09-30: the requirement moves to the 2.10 line, and 2.10.0 is + # REQUIRED: `statifier_router` 0.9.2, below, states + # `{:statifier, "~> 2.10"}`. 2.10.0 ships `Statifier.Send.BasicHTTP`, + # the Basic HTTP Event I/O Processor and its decoder, which + # `StatifierExamples.HoldDesk` sends through and the router's front + # decodes with. + {:statifier, "~> 2.10"}, # A note on every `statifier_persistence` name below, added with # se-20j. These comments record why each floor moved, release by @@ -384,6 +391,14 @@ defmodule StatifierExamples.MixProject do # content-hash query the batch lists with, and still declares no # chart retirement: on this SQLite database `retire_chart/4` answers # `{:error, :chart_retirement_unsupported}`, as the guide shows. + # + # 2026-09-30: `mix.lock` moves to 0.24.0, which the update to + # `statifier_router` 0.9.2 brought with it; the requirement stays. None + # of 0.22.0, 0.23.0 and 0.24.0 adds a migration. 0.22.0 refuses a + # migration plan that keeps a timer for an event the new chart no + # longer handles, 0.23.0 closes a race with a chart retirement this + # adapter does not offer, and 0.24.0 renames the conformance suite's + # helpers, which this app reaches only through the case template. {:statifier_persistence, "~> 0.20"}, # Durable timers. `statifier_oban` never owns an Oban instance @@ -569,7 +584,23 @@ defmodule StatifierExamples.MixProject do # `:unmatched_event` drop and the `reason` on every # `:unregistered_routes` entry reach `StatifierExamples.Publish` # through a `%{route: _, location: _}` match that still holds. - {:statifier_router, "~> 0.6.0"}, + # + # 2026-09-30: the requirement moves to `~> 0.9.2`, and 0.9.2 is + # REQUIRED: it is the first release whose migrations and address + # reaper both run on this app's SQLite database. 0.8.0 added V03, + # which renames the subscription index with an `ALTER INDEX` SQLite + # does not have, so the migration above failed on a fresh database + # until 0.9.1 made V03 do nothing on SQLite; 0.8.0 also wrote + # `Addresses.reap/3`'s stamp and delete with Postgres's `= ANY(...)`, + # which failed `StatifierExamples.RoutedWorkflow.AddressReaper` until + # 0.9.2 wrote them as an IN list. 0.9.0 ships what `StatifierExamples.HoldDesk` + # uses: the `:basichttp` configuration key, a location for each + # execution it creates, `StatifierRouter.BasicHTTP.Front` and the + # location table, which + # `priv/repo/migrations/20260930120001_add_statifier_router_locations.exs` + # creates. 0.7.0's `:unchecked` entry for a `:bindings_resolver` + # reaches nothing here, since no configuration sets one. + {:statifier_router, "~> 0.9.2"}, # The observing/authoring component library, declared DIRECTLY rather # than taken transitively. `statifier_ui` is an OPTIONAL dependency of diff --git a/mix.lock b/mix.lock index 094cc37..60693e6 100644 --- a/mix.lock +++ b/mix.lock @@ -44,12 +44,12 @@ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "predicator": {:hex, :predicator, "9.4.2", "2f4e52255330657d2bafada287b989357b4975be4c2052bf0d4a4c4a0666168a", [:mix], [], "hexpm", "893c35dc4f982c08d8d045ae742c510cd5b2c3d9d8a447c45ca0e4e52aec9b1e"}, "saxy": {:hex, :saxy, "1.6.1", "742eff28f553c066d0b54e84662dbf384a1d1f38595472ed15f6e0a33038bbe1", [:mix], [], "hexpm", "8989d504424ba29460a61950f8968380651413fa05e63b6118084db057da1a6b"}, - "statifier": {:hex, :statifier, "2.9.0", "ab7605abd7648026f49f940a7eb2bba17b9dff0a7050b70d018d5444356dd0ee", [:mix], [{:predicator, "~> 9.0", [hex: :predicator, repo: "hexpm", optional: false]}, {:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0f790878618aec8f65f8780dae2485bb88ba3bee42b538eee6bad1fd5b90e969"}, + "statifier": {:hex, :statifier, "2.10.0", "d281b4a056dcd4870462b000e0c880abe0ea97948c36151bc47127e6b6cf2012", [:mix], [{:predicator, "~> 9.0", [hex: :predicator, repo: "hexpm", optional: false]}, {:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1ebf8b721547666a5c817c46a813f61973ed303d97ae21781bc41c78ff7f6ed5"}, "statifier_blocks": {:hex, :statifier_blocks, "0.41.0", "e4a94580a3d811bde202dbb6390f119e9a92d8ec6b018bee1c7743185b9d2987", [:mix], [{:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:predicator, "~> 9.4.1", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.2", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_datamodel, "~> 0.4", [hex: :statifier_datamodel, repo: "hexpm", optional: false]}, {:statifier_ui, "~> 0.9", [hex: :statifier_ui, repo: "hexpm", optional: true]}], "hexpm", "3635344a8296bd70a59f74be3d77009f588cc9c567b857baed8d2864bc2e14d0"}, "statifier_datamodel": {:hex, :statifier_datamodel, "0.5.0", "72a04a1d672e9734b324b5aa164abd49f388cc921283edc22fbe8b22e371eca7", [:mix], [], "hexpm", "1d4e1f5ba659d80713f5c901343bac65ddedea2dfd984666fd60d5ddcd623f2a"}, "statifier_oban": {:hex, :statifier_oban, "0.13.0", "c95b0dd9b82e1c4355cb60085c088e662ecedcc307c6c1f11f4d4c4ace62f153", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:statifier, "~> 2.5", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_persistence, "~> 0.13", [hex: :statifier_persistence, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e6ed294eaf9b07b3a05ca420e3d247a867ae316f2dfbba27d470239f64a6bcc"}, - "statifier_persistence": {:hex, :statifier_persistence, "0.21.0", "4c84d498f9fa6d68b3b17bba5b117698f571f207805a912aba447f94933bed06", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:statifier, "~> 2.9", [hex: :statifier, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "139fe627f85a92c41c30d4437e15b46fb249c25e7f5663fb719cbbcbc7f1e6ae"}, - "statifier_router": {:hex, :statifier_router, "0.6.0", "38381a88c208da1d19168286ba3e7f75029989ee5249cbd45a3a0f4f7d8e6235", [:mix], [{:broadway, "~> 1.3", [hex: :broadway, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:predicator, "~> 9.4", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.9", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_persistence, "~> 0.18", [hex: :statifier_persistence, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "0e8b97986c3c75cccc850ae193ab653b88fdc16a98df1442d8ecd7e891b52946"}, + "statifier_persistence": {:hex, :statifier_persistence, "0.24.0", "43cbeb2e2f818e3bb80f636e208892f27049b990773db850fabd9bec44585dfd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:statifier, "~> 2.9", [hex: :statifier, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "fa10f4a40d34d9d4db196b8afb1b20b4714e802f6aa4fe6bf95c61aea60cf7ce"}, + "statifier_router": {:hex, :statifier_router, "0.9.2", "d3aaf1e0264fbe6a7a87fac46bee8f808d8da10085437e2fdbb8656adcac71fa", [:mix], [{:broadway, "~> 1.3", [hex: :broadway, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:predicator, "~> 9.4", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.10", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_persistence, "~> 0.18", [hex: :statifier_persistence, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:uxid, "~> 2.0", [hex: :uxid, repo: "hexpm", optional: false]}], "hexpm", "692ab40898c5cf01eae035cf824971d6278f26802ff8ce31ef3450abc83e317c"}, "statifier_ui": {:hex, :statifier_ui, "0.10.1", "fcd918b5259e4c8ce5430c3cf2075f4d13aaa52135cb563713db526b8bec9493", [:mix], [{:kino, "~> 0.14", [hex: :kino, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:predicator, "~> 9.4", [hex: :predicator, repo: "hexpm", optional: false]}, {:statifier, "~> 2.5", [hex: :statifier, repo: "hexpm", optional: false]}, {:statifier_datamodel, "~> 0.4", [hex: :statifier_datamodel, repo: "hexpm", optional: false]}], "hexpm", "cb4992baecefc40af76d587408b71cd94ceecd392374926ecaef07fe49211069"}, "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, diff --git a/priv/library/hold_desk.scxml b/priv/library/hold_desk.scxml new file mode 100644 index 0000000..d9b1bdd --- /dev/null +++ b/priv/library/hold_desk.scxml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/priv/repo/migrations/20260930120001_add_statifier_router_locations.exs b/priv/repo/migrations/20260930120001_add_statifier_router_locations.exs new file mode 100644 index 0000000..bb408c4 --- /dev/null +++ b/priv/repo/migrations/20260930120001_add_statifier_router_locations.exs @@ -0,0 +1,23 @@ +defmodule StatifierExamples.Repo.Migrations.AddStatifierRouterLocations do + @moduledoc """ + `statifier_router`'s location table (its V04), which only a router + configuration that sets `:basichttp` needs: `StatifierExamples.HoldDesk` + keeps each execution's BasicHTTP location token there, beside its + address row. + + The table is opt-in and outside the router's version walk, so it is its + own migration after `20260925120001_add_statifier_router.exs`, with the + same `depot_id` leading column that migration gives every router table. + It references the address table, and Ecto's rollback undoes this + migration first. `down_locations/1` drops the table only if it is + there. It runs on SQLite, with no `:prefix`. + """ + + use Ecto.Migration + + @opts [leading_columns: [depot_id: {:text, null: true}]] + + def up, do: StatifierRouter.Migrations.up_locations(@opts) + + def down, do: StatifierRouter.Migrations.down_locations(@opts) +end diff --git a/test/statifier_examples/first_workflow_test.exs b/test/statifier_examples/first_workflow_test.exs index 3b19af5..40217db 100644 --- a/test/statifier_examples/first_workflow_test.exs +++ b/test/statifier_examples/first_workflow_test.exs @@ -32,7 +32,7 @@ defmodule StatifierExamples.FirstWorkflowTest do assert {:ok, lines} = FirstWorkflow.run(execution_id: execution_id) assert [ - "first workflow on statifier 2.9." <> _pins, + "first workflow on statifier 2.10." <> _pins, "expressible bdoc_hold_pickup against the host palette", "published every publish-time check passed, 0 warning(s)", "registered chart sha256:" <> _hash, diff --git a/test/statifier_examples/migrate_waiting_test.exs b/test/statifier_examples/migrate_waiting_test.exs index f87e746..b7134c0 100644 --- a/test/statifier_examples/migrate_waiting_test.exs +++ b/test/statifier_examples/migrate_waiting_test.exs @@ -30,7 +30,7 @@ defmodule StatifierExamples.MigrateWaitingTest do assert {:ok, lines} = MigrateWaiting.walk(loans: loans) assert [ - "migrate waiting on statifier_persistence 0.21." <> _patch, + "migrate waiting on statifier_persistence 0.24." <> _patch, "waiting 2 loan(s) on revision 1, sha256:" <> _old, "published revision 2, sha256:" <> _new, "diff breaking (5 unresolved) with the blocks mapping, " <> diff --git a/test/statifier_examples/mix_deps_test.exs b/test/statifier_examples/mix_deps_test.exs index 5755750..c4195f8 100644 --- a/test/statifier_examples/mix_deps_test.exs +++ b/test/statifier_examples/mix_deps_test.exs @@ -682,10 +682,17 @@ defmodule StatifierExamples.MixDepsTest do # this app reads neither. Sabotage: pointed the LOCK assertion back # at `"2.8.` and left `mix.lock` alone; it went red reporting the resolved # 2.9.0 entry. Reverted from a copy. + # + # 2026-09-30: the engine moves to the 2.10 line, and 2.10.0 is REQUIRED: + # `statifier_router` 0.9.2 states `{:statifier, "~> 2.10"}`, the release + # that ships the Basic HTTP Event I/O Processor and its decoder, which + # `StatifierExamples.HoldDesk` sends through. Sabotage: pointed the LOCK + # assertion back at `"2.9.` and left `mix.lock` alone; it went red + # reporting the resolved 2.10.0 entry. Reverted from a copy. test "the statifier dep is the Hex requirement, with no override" do deps = Mix.Project.config()[:deps] - assert {:statifier, "~> 2.9"} in deps + assert {:statifier, "~> 2.10"} in deps lock_line = "mix.lock" @@ -694,7 +701,7 @@ defmodule StatifierExamples.MixDepsTest do |> Enum.find(&String.starts_with?(&1, ~s( "statifier": ))) assert lock_line, "statifier has no mix.lock entry" - assert lock_line =~ ~s({:hex, :statifier, "2.9.) + assert lock_line =~ ~s({:hex, :statifier, "2.10.) end # The durable stepper. 0.3.0 was the floor two release lines back, as @@ -919,6 +926,15 @@ defmodule StatifierExamples.MixDepsTest do # Sabotage: pointed the LOCK assertion back at `"0.19.` and left # `mix.lock` alone; it went red reporting the resolved 0.21.0 entry. # Reverted from a copy. + # + # 2026-09-30: the requirement stays `~> 0.20`, and `mix.lock` resolves + # 0.24.0, which the update to `statifier_router` 0.9.2 brought with it. + # None of 0.22.0, 0.23.0 and 0.24.0 adds a migration; 0.24.0 renames the + # conformance suite's helpers, which this app's conformance test uses + # only through the case template; the migration guide's test holds the + # one plan it builds against 0.22.0's new `migrate/4` refusal. Sabotage: + # pointed the LOCK assertion back at `"0.21.` and left `mix.lock` alone; + # it went red reporting the resolved 0.24.0 entry. Reverted from a copy. test "the statifier_persistence dep is the Hex requirement" do deps = Mix.Project.config()[:deps] @@ -931,7 +947,7 @@ defmodule StatifierExamples.MixDepsTest do |> Enum.find(&String.starts_with?(&1, ~s( "statifier_persistence": ))) assert lock_line, "statifier_persistence has no mix.lock entry" - assert lock_line =~ ~s({:hex, :statifier_persistence, "0.21.) + assert lock_line =~ ~s({:hex, :statifier_persistence, "0.24.) refute lock_line =~ ":git," end @@ -962,10 +978,19 @@ defmodule StatifierExamples.MixDepsTest do # through a `%{route: _, location: _}` match that still holds. Sabotage: # pointed the LOCK assertion back at `"0.4.` and left `mix.lock` alone; it # went red reporting the resolved 0.6.0 entry. Reverted from a copy. + # + # 2026-09-30: the requirement moves to `~> 0.9.2`, and 0.9.2 is REQUIRED: + # 0.9.0 ships the BasicHTTP front and the location table + # `StatifierExamples.HoldDesk` uses, and 0.9.2 is the first release whose + # migrations and address reaper both run on this app's SQLite database + # (0.8.0's V03 and its `Addresses.reap/3` did not). Sabotage: pointed the + # LOCK assertion back at `"0.6.` and left `mix.lock` alone; it went red + # reporting the resolved 0.9.2 entry. + # Reverted from a copy. test "the statifier_router dep is the Hex requirement" do deps = Mix.Project.config()[:deps] - assert {:statifier_router, "~> 0.6.0"} in deps + assert {:statifier_router, "~> 0.9.2"} in deps lock_line = "mix.lock" @@ -974,7 +999,7 @@ defmodule StatifierExamples.MixDepsTest do |> Enum.find(&String.starts_with?(&1, ~s( "statifier_router": ))) assert lock_line, "statifier_router has no mix.lock entry" - assert lock_line =~ ~s({:hex, :statifier_router, "0.6.) + assert lock_line =~ ~s({:hex, :statifier_router, "0.9.2) refute lock_line =~ ":git," end diff --git a/test/statifier_examples/routed_workflow_test.exs b/test/statifier_examples/routed_workflow_test.exs index 299f04f..9b439f2 100644 --- a/test/statifier_examples/routed_workflow_test.exs +++ b/test/statifier_examples/routed_workflow_test.exs @@ -36,7 +36,7 @@ defmodule StatifierExamples.RoutedWorkflowTest do assert {:ok, lines} = RoutedWorkflow.run(parcel_id: "parcel-test") assert [ - "first workflow routed on statifier_router 0.6." <> _pins, + "first workflow routed on statifier_router 0.9." <> _pins, "migrated 4 router tables, depot_id at position 2 on each", "published bdoc_parcel_route accepts parcel.scanned; " <> "every publish-time check passed, 0 warning(s)", diff --git a/test/statifier_examples_web/controllers/basic_http_controller_test.exs b/test/statifier_examples_web/controllers/basic_http_controller_test.exs new file mode 100644 index 0000000..10e47c7 --- /dev/null +++ b/test/statifier_examples_web/controllers/basic_http_controller_test.exs @@ -0,0 +1,150 @@ +defmodule StatifierExamplesWeb.BasicHTTPControllerTest do + use StatifierExamplesWeb.ConnCase, async: false + + alias StatifierExamples.{FirstWorkflow, HoldDesk, RoutedWorkflow} + alias StatifierPersistence.{Execution, Executions, Storage} + alias StatifierRouter.BasicHTTP + + # A patron's hold on a copy at the Riverside branch: the execution tells + # the desk it was placed, handing it the location to answer at, and the + # desk posts copy.shelved there through the controller. + + @scope "branch_riverside" + @desk "https://riverside.example/holds-desk" + @form "application/x-www-form-urlencoded" + + setup do + {:ok, _content_hash} = HoldDesk.register() + :ok + end + + defp placed_hold(hold_id \\ "hold-0417") do + assert {:ok, [{:created_and_delivered, "hold_requests", execution_id}]} = + HoldDesk.request(@scope, %{ + "hold_id" => hold_id, + "copy_id" => "copy-2231", + "desk" => @desk + }) + + assert_received {:desk_post, @desk, headers, body} + {execution_id, headers, URI.decode_query(body)} + end + + defp token(location), do: String.replace_prefix(location, HoldDesk.base_url() <> "/", "") + + defp post_event(conn, location, body, headers \\ []) do + conn = + Enum.reduce([{"content-type", @form} | headers], conn, fn {name, value}, conn -> + put_req_header(conn, name, value) + end) + + post(conn, "/basichttp/" <> token(location), body) + end + + defp status!(execution_id) do + {:ok, record} = Storage.fetch_execution(FirstWorkflow.store(), execution_id) + Execution.from_record(record).status + end + + # sabotage: the :basichttp key dropped from HoldDesk.config/0 -> the + # chart's basichttp send was an unsupported type, no desk_post arrived + # and the route answered an error, red; restored, green. + # sabotage: execute/2's BasicHTTP clause made to answer :ok without + # performing -> assert_received {:desk_post, ...} failed, red; restored, + # green. + test "the hold tells the desk it was placed, with its own location to answer at" do + {execution_id, headers, params} = placed_hold() + + assert params["_scxmleventname"] == "hold.placed" + assert params["hold_id"] == "hold-0417" + assert params["copy_id"] == "copy-2231" + assert {:ok, location} = BasicHTTP.location(HoldDesk.config(), execution_id) + assert params["reply_to"] == location + assert String.starts_with?(location, HoldDesk.base_url() <> "/") + refute String.contains?(location, execution_id) + assert {"content-type", @form} in headers + assert Enum.any?(headers, &match?({"scxml-send-key", _key}, &1)) + assert status!(execution_id) == :active + end + + # sabotage: the controller handed the front the parsed body ("") instead + # of the raw one -> the event decoded as HTTP.POST, the execution stayed + # active, red; restored, green. + # sabotage: Front.response/1's status replaced with a constant 200 in the + # controller -> red on the 204; restored, green. + test "a POST at the location through the controller delivers the desk's event", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + conn = post_event(conn, location, "_scxmleventname=copy.shelved") + + assert response(conn, 204) == "" + assert status!(execution_id) == :completed + + assert {:ok, [_requested, %{event: %{name: "copy.shelved"}}]} = + Executions.inputs(FirstWorkflow.store(), execution_id) + end + + test "a finished hold and an unknown location answer 404", %{conn: conn} do + {_execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + assert conn |> post_event(location, "_scxmleventname=copy.shelved") |> response(204) + + assert build_conn() |> post_event(location, "_scxmleventname=copy.shelved") |> response(404) + + unknown = HoldDesk.base_url() <> "/" <> BasicHTTP.mint_token() + assert build_conn() |> post_event(unknown, "_scxmleventname=copy.shelved") |> response(404) + end + + # sabotage: the controller handed the front "POST" whatever the method + # -> the GET was delivered and answered 204, red; restored, green. + test "another method answers 405 with allow: POST", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + conn = get(conn, "/basichttp/" <> token(location)) + + assert response(conn, 405) == "" + assert get_resp_header(conn, "allow") == ["POST"] + assert status!(execution_id) == :active + end + + test "a malformed send key answers 400 and delivers nothing", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + + conn = + post_event(conn, location, "_scxmleventname=copy.shelved", [ + {"scxml-send-key", "not-eight-fields"} + ]) + + assert response(conn, 400) == "" + assert status!(execution_id) == :active + end + + test "a repeated send key is delivered once", %{conn: conn} do + {execution_id, _headers, %{"reply_to" => location}} = placed_hold() + key = [{"scxml-send-key", "sess_desk/shelved_1/1/1/0/0/onentry.0.0/0"}] + + assert conn |> post_event(location, "_scxmleventname=noted", key) |> response(204) + assert build_conn() |> post_event(location, "_scxmleventname=noted", key) |> response(204) + + assert {:ok, [_requested, %{event: %{name: "noted"}}]} = + Executions.inputs(FirstWorkflow.store(), execution_id) + end + + # sabotage: execute/2 made to answer :ok whatever perform/2 answered -> + # the execution stayed active in waiting and the location still took a + # POST, red; restored, green. + test "a desk that refuses the POST ends the hold unreached", %{conn: conn} do + Process.put(:desk_status, 503) + {execution_id, _headers, %{"reply_to" => location}} = placed_hold("hold-0418") + + assert status!(execution_id) == :completed + assert conn |> post_event(location, "_scxmleventname=copy.shelved") |> response(404) + end + + # sabotage: :basichttp added to RoutedWorkflow's configuration -> red; + # restored, green. + test "the parcel configuration carries no BasicHTTP location" do + assert RoutedWorkflow.config().basichttp == nil + assert HoldDesk.config().basichttp[:base_url] == HoldDesk.base_url() + end +end diff --git a/test/support/desk_transport.ex b/test/support/desk_transport.ex new file mode 100644 index 0000000..295482f --- /dev/null +++ b/test/support/desk_transport.ex @@ -0,0 +1,17 @@ +defmodule StatifierExamples.DeskTransport do + @moduledoc """ + The test transport for `StatifierExamples.HoldDesk`'s outbound + BasicHTTP POSTs: it sends `{:desk_post, url, headers, body}` to the + process performing the POST, which is the test process that routed the + hold request, and answers the status the process put under + `:desk_status`, 204 as a branch desk would when it put none. + """ + + @behaviour Statifier.Send.BasicHTTP.Transport + + @impl true + def post(url, headers, body) do + send(self(), {:desk_post, url, headers, body}) + {:ok, Process.get(:desk_status, 204)} + end +end From a054c1f474e9290fc8436081784bf7f22bd2985f Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 30 Sep 2026 15:06:03 -0600 Subject: [PATCH 2/2] Keeps the BasicHTTP token out of the request log A location's token rode in the path of every desk POST, and Phoenix logged that path at :info with the request line. The endpoint's Plug.Telemetry now takes log_level/1, which logs no request line under /basichttp; the route is log: false; and :filter_parameters names token. A test captures a POST at :debug and finds the token in no log line but Ecto's query lines, which print bound parameters at :debug; the guide and the controller's moduledoc say so. Refs: se-jg43 --- config/config.exs | 4 +++ docs/guides/basichttp-front.md | 9 +++++- lib/statifier_examples/hold_desk.ex | 3 +- .../controllers/basic_http_controller.ex | 7 ++++- lib/statifier_examples_web/endpoint.ex | 11 ++++++- lib/statifier_examples_web/router.ex | 5 ++-- .../basic_http_controller_test.exs | 29 +++++++++++++++++++ 7 files changed, 62 insertions(+), 6 deletions(-) diff --git a/config/config.exs b/config/config.exs index bd47074..a29c348 100644 --- a/config/config.exs +++ b/config/config.exs @@ -117,6 +117,10 @@ config :logger, :default_formatter, # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason +# A BasicHTTP location's token is a bearer capability, so a params log +# names it filtered, beside Phoenix's own default. +config :phoenix, :filter_parameters, ["password", "token"] + # OpenTelemetry. `opentelemetry_statifier` brings only the API, so the SDK's # exporter is this app's choice - and the default choice is none. An example # app that shipped an OTLP exporter on by default would spend every boot diff --git a/docs/guides/basichttp-front.md b/docs/guides/basichttp-front.md index 4b4cf92..5d7a417 100644 --- a/docs/guides/basichttp-front.md +++ b/docs/guides/basichttp-front.md @@ -19,7 +19,14 @@ which drives the whole of it through the controller. Anyone who holds a location can post events to that execution, and the router authenticates nothing beyond possession of it (ruled by the operator, 2026-09-30). The hold hands its location to the one desk its -request names and to nobody else, and nothing here logs it. A host serves +request names and to nobody else. No request line or dispatch log carries +it: the endpoint's `Plug.Telemetry` logs no request line under +`/basichttp` (`StatifierExamplesWeb.Endpoint.log_level/1`), the route is +`log: false`, and `:filter_parameters` names `token`. Ecto's query log at +`:debug` prints bound parameters, and the router binds the token to look +a location up and to store it, so a host keeps `:debug` out of +production; at `:info`, the production level here, no query is logged. +A host serves the base URL over TLS and rotates a location that may have leaked with `StatifierRouter.BasicHTTP.rotate_location/2`, after which the old one answers 404. diff --git a/lib/statifier_examples/hold_desk.ex b/lib/statifier_examples/hold_desk.ex index 8f83796..d4bf267 100644 --- a/lib/statifier_examples/hold_desk.ex +++ b/lib/statifier_examples/hold_desk.ex @@ -17,7 +17,8 @@ defmodule StatifierExamples.HoldDesk do 2026-09-30): anyone who holds it can post events to that execution, and the router authenticates nothing beyond possession of it. This module hands the location to the desk the hold request names and to nobody - else, and never logs it. + else, and never logs it; `StatifierExamplesWeb.BasicHTTPController` + says what keeps it out of the request log. `config/0` is a router configuration of its own, separate from `StatifierExamples.RoutedWorkflow.config/0`: only this configuration diff --git a/lib/statifier_examples_web/controllers/basic_http_controller.ex b/lib/statifier_examples_web/controllers/basic_http_controller.ex index ba9acd5..5183333 100644 --- a/lib/statifier_examples_web/controllers/basic_http_controller.ex +++ b/lib/statifier_examples_web/controllers/basic_http_controller.ex @@ -10,7 +10,12 @@ defmodule StatifierExamplesWeb.BasicHTTPController do The token is a bearer capability (ruled by the operator, 2026-09-30): holding it is the whole of the authorization, so this action checks - nothing else and never logs it. + nothing else. No request line or dispatch log carries it: + `StatifierExamplesWeb.Endpoint.log_level/1` skips the request line for + `/basichttp`, the route is `log: false`, and `:filter_parameters` names + `token`. At `:debug` Ecto's query log prints bound parameters, and the + router's location lookup binds the token, so a host keeps `:debug` out + of production. """ use StatifierExamplesWeb, :controller diff --git a/lib/statifier_examples_web/endpoint.ex b/lib/statifier_examples_web/endpoint.ex index f774668..8a3f140 100644 --- a/lib/statifier_examples_web/endpoint.ex +++ b/lib/statifier_examples_web/endpoint.ex @@ -36,7 +36,7 @@ defmodule StatifierExamplesWeb.Endpoint do end plug Plug.RequestId - plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint], log: {__MODULE__, :log_level, []} plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], @@ -48,4 +48,13 @@ defmodule StatifierExamplesWeb.Endpoint do plug Plug.Head plug Plug.Session, @session_options plug StatifierExamplesWeb.Router + + @doc """ + The level `Plug.Telemetry` logs a request line at: none for a request + under `/basichttp`, whose path carries a BasicHTTP location's token, a + bearer capability that must not reach a log; `:info` for every other. + """ + @spec log_level(Plug.Conn.t()) :: Logger.level() | false + def log_level(%Plug.Conn{path_info: ["basichttp" | _]}), do: false + def log_level(_conn), do: :info end diff --git a/lib/statifier_examples_web/router.ex b/lib/statifier_examples_web/router.ex index 312e3f5..ccd3c72 100644 --- a/lib/statifier_examples_web/router.ex +++ b/lib/statifier_examples_web/router.ex @@ -26,9 +26,10 @@ defmodule StatifierExamplesWeb.Router do # The BasicHTTP front: a location of a durable execution. No pipeline: # the request is a machine's POST, not a browser's, and every method - # reaches the action so the front can answer 405 itself. + # reaches the action so the front can answer 405 itself. The dispatch is + # not logged: its path and params carry the location's token. scope "/basichttp", StatifierExamplesWeb do - match :*, "/:token", BasicHTTPController, :event + match :*, "/:token", BasicHTTPController, :event, log: false end # Other scopes may use custom stacks. diff --git a/test/statifier_examples_web/controllers/basic_http_controller_test.exs b/test/statifier_examples_web/controllers/basic_http_controller_test.exs index 10e47c7..455ef75 100644 --- a/test/statifier_examples_web/controllers/basic_http_controller_test.exs +++ b/test/statifier_examples_web/controllers/basic_http_controller_test.exs @@ -1,6 +1,8 @@ defmodule StatifierExamplesWeb.BasicHTTPControllerTest do use StatifierExamplesWeb.ConnCase, async: false + import ExUnit.CaptureLog + alias StatifierExamples.{FirstWorkflow, HoldDesk, RoutedWorkflow} alias StatifierPersistence.{Execution, Executions, Storage} alias StatifierRouter.BasicHTTP @@ -95,6 +97,33 @@ defmodule StatifierExamplesWeb.BasicHTTPControllerTest do assert build_conn() |> post_event(unknown, "_scxmleventname=copy.shelved") |> response(404) end + # Ecto's own query lines are set aside: at :debug they print bound + # parameters, which the guide says. They also prove the capture is live. + # sabotage: the endpoint's Plug.Telemetry log option removed -> the + # request line carried the token, red; restored, green. + # sabotage: the route's log: false removed and "token" dropped from + # :filter_parameters -> the dispatch params carried the token, red; + # restored, green. + test "a POST at the location leaves the token out of the request log", %{conn: conn} do + {_execution_id, _headers, %{"reply_to" => location}} = placed_hold() + level = Logger.level() + Logger.configure(level: :debug) + on_exit(fn -> Logger.configure(level: level) end) + + log = + capture_log([level: :debug], fn -> + assert conn |> post_event(location, "_scxmleventname=copy.shelved") |> response(204) + end) + + {queries, others} = + log + |> String.split(~r/^(?=\d{2}:\d{2}:\d{2}\.\d{3} )/m, trim: true) + |> Enum.split_with(&(&1 =~ ~r/\] QUERY (OK|ERROR)/)) + + assert queries != [] + refute Enum.any?(others, &(&1 =~ token(location))) + end + # sabotage: the controller handed the front "POST" whatever the method # -> the GET was delivered and answered 204, red; restored, green. test "another method answers 405 with allow: POST", %{conn: conn} do