From 54ca651f377a8705b51f55273741760f9b33d8a3 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Tue, 6 Oct 2026 16:08:57 +0000 Subject: [PATCH] =?UTF-8?q?Tablet=2046:=20the=20watch=20=E2=80=94=20rate?= =?UTF-8?q?=20limits=20at=20every=20gate,=20per=20IP=20at=20the=20pe'ah,?= =?UTF-8?q?=20per=20key=20inside?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tamid §1.1 wrote the pe'ah's limit (10/min, 100/h per IP); nothing enforced it, on any tier, and every admitted request spends manna. VavPipeline.BigdeiKodesh.Mishmar: fixed minute and hour windows per subject in ETS, the subject stored only as sha256(boot salt, tier, subject) — no IP and no key is ever in memory as itself, the sweep drops closed windows, nothing survives a restart (the Tamid's "not logged beyond the current request", kept in memory too). A refused knock is not counted. 429 RATE_LIMITED with Retry-After and the manna explanation (Shemot 16:18). Pe'ah per peer address; courtyard and chatzer per key, after the gate (a wrong key is 401 and never counted). Numbers: pe'ah 10/100 (Tamid), courtyard 20/200 and chatzer 5/60 (from Construction §2.3; config :rate_limits). Service shorashim-external: externalTrafficPolicy Local. With Cluster the pod saw every caller as the node, 91.92.153.113 (observed via /proc/net/tcp) — a per-IP limit would have been one bucket for the world. Spec: tamid §1.1 (watch, 429 rows, the Service note), KOHEN.md. Doorpost 42b38626… → e4e3d56d…. tablet46_mishmar_test; Elixir 533/0 (L3 on and off). Co-Authored-By: Claude Fable 5.1 --- KOHEN.md | 4 + deploy/k8s/service.yaml | 6 + specs/shorashim_tamid.md | 14 ++ vav_pipeline/config/config.exs | 9 +- vav_pipeline/config/test.exs | 7 + vav_pipeline/lib/vav_pipeline/application.ex | 3 +- .../lib/vav_pipeline/bigdei_kodesh/gate.ex | 4 +- .../lib/vav_pipeline/bigdei_kodesh/mishmar.ex | 180 ++++++++++++++++++ .../lib/vav_pipeline/bigdei_kodesh/router.ex | 58 ++++-- vav_pipeline/lib/vav_pipeline/doorpost.ex | 2 +- vav_pipeline/test/fixtures/manna/ledger.json | 1 - vav_pipeline/test/tablet46_mishmar_test.exs | 135 +++++++++++++ 12 files changed, 406 insertions(+), 17 deletions(-) create mode 100644 vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex delete mode 100644 vav_pipeline/test/fixtures/manna/ledger.json create mode 100644 vav_pipeline/test/tablet46_mishmar_test.exs diff --git a/KOHEN.md b/KOHEN.md index 01c8e0b4..7dfe3d6c 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -507,6 +507,10 @@ tail -1 "$TMP" # hand this line's key to its bearer, then rm -f "$TMP" ``` +Each key may knock 20 times a minute / 200 an hour at the courtyard +and 5 / 60 at the chatzer; the pe'ah is 10 / 100 per IP. Over that the +caller gets 429 with `Retry-After` (Tamid §1.1). + To revoke: remove the line the same way. Without the Secret the pod does not start; with an empty file nobody passes (`GATE_NO_KEYS` in the vav-pipeline log). diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml index e6f9ec7e..a68e6b56 100644 --- a/deploy/k8s/service.yaml +++ b/deploy/k8s/service.yaml @@ -46,6 +46,12 @@ metadata: service.beta.kubernetes.io/exoscale-loadbalancer-name: "shorashim" spec: type: LoadBalancer + # Local: kube-proxy does not SNAT, so the pod sees the caller's own + # address and the pe'ah's per-IP watch (Tamid §1.1) counts callers, + # not the node. With Cluster (the default) every caller arrived as the + # node IP 91.92.153.113 — observed 2026-10-06, tablet 46. One node, + # one replica: Local loses nothing. + externalTrafficPolicy: Local selector: app.kubernetes.io/name: shorashim ports: diff --git a/specs/shorashim_tamid.md b/specs/shorashim_tamid.md index 94933da9..7801aba9 100644 --- a/specs/shorashim_tamid.md +++ b/specs/shorashim_tamid.md @@ -47,6 +47,9 @@ Rate limit: 10 requests per minute per IP 100 requests per hour per IP No identification stored. IP is used for rate-limiting only, not logged beyond the current request. + 429 carries Retry-After and the manna explanation. + (The Service must deliver the caller's address to the pod — + externalTrafficPolicy: Local — or every caller is one IP.) ``` **Design requirements:** No cookies. No session state. No analytics. No tracking pixels. No JavaScript fingerprinting. The pe'ah endpoint collects nothing. The stranger is safe to approach. @@ -62,6 +65,16 @@ file or no keys: nobody passes, and the log says `GATE_NO_KEYS`. The caller is never told why beyond "missing or invalid API key". Issued by the kohen (KOHEN.md "Issuing a key"); the key never appears in a log. +**The watch.** Past the gate, each key may knock 20 times a minute and +200 times an hour at the courtyard (Construction §2.3: 200 korbanot an +hour is the system's peak — one key may not be the whole peak), 5 a +minute and 60 an hour at the chatzer (five panels at once). The pe'ah's +10/100 per IP is above. Counted per key, not per IP; a refused key is +never counted. The counters are salted hashes in memory, swept as the +windows close — no list of who called exists. 429 carries `Retry-After` +and the manna explanation. The numbers are configuration +(`config :vav_pipeline, :rate_limits`); the pe'ah's are this document's. + ``` Endpoint: POST /courtyard/offering @@ -174,6 +187,7 @@ Errors: 401 — Invalid or missing API key 403 — A key for an outer ring at an inner gate 422 — Missing or empty output, output over 65,536 bytes, unknown model + 429 — Rate limit exceeded (20/min, 200/h per key). Retry-After; manna explanation. 503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE, TALMUDIC_UNAVAILABLE. Body says which; resubmit: true. diff --git a/vav_pipeline/config/config.exs b/vav_pipeline/config/config.exs index 63298222..2ea5387e 100644 --- a/vav_pipeline/config/config.exs +++ b/vav_pipeline/config/config.exs @@ -20,7 +20,14 @@ config :vav_pipeline, korban_socket_path: "/tmp/shorashim/korban.sock", # Courtyard / chatzer keys (BigdeiKodesh.Gate). nil → the gate is shut. # Construction §4.6: a file, never an environment variable. - api_keys_file: nil + api_keys_file: nil, + # The watch (BigdeiKodesh.Mishmar). pe'ah is Tamid §1.1 verbatim; the + # other two derive from Construction §2.3 and may be tuned here. + rate_limits: [ + peah: [minute: 10, hour: 100], + courtyard: [minute: 20, hour: 200], + chatzer: [minute: 5, hour: 60] + ] # L3 — Oral Tradition (PostgreSQL) # Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE. diff --git a/vav_pipeline/config/test.exs b/vav_pipeline/config/test.exs index 7615199b..aaf39aab 100644 --- a/vav_pipeline/config/test.exs +++ b/vav_pipeline/config/test.exs @@ -7,6 +7,13 @@ config :vav_pipeline, l1_mount_point: "../l1", manna_provision_file: "test/fixtures/manna/today.json", api_keys_file: "test/fixtures/api_keys", + # Wide open for the suite (Day 6/7 tests knock many times a second); + # tablet46_mishmar_test narrows them and resets. + rate_limits: [ + peah: [minute: 10_000, hour: 100_000], + courtyard: [minute: 10_000, hour: 100_000], + chatzer: [minute: 10_000, hour: 100_000] + ], cycle_boundary_check_interval_ms: :timer.hours(24), http_port: 0 diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 3432dbb9..f4f2cd6f 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -47,7 +47,8 @@ defmodule VavPipeline.Application do {VavPipeline.VoiceMonitor, []}, {VavPipeline.MachtzitHaShekel, []}, # Tablet 30 — the index that lets a question name its own verse - {VavPipeline.L1Index, []} + {VavPipeline.L1Index, []}, + {VavPipeline.BigdeiKodesh.Mishmar, []} ] |> maybe_add_http_server() |> maybe_add_repo() diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex index a1cd543d..d4d6e732 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex @@ -85,7 +85,9 @@ defmodule VavPipeline.BigdeiKodesh.Gate do # ------------------------------------------------------------ - defp bearer(conn) do + @doc "The bearer key on the request, if the header has one." + @spec bearer(Plug.Conn.t()) :: {:ok, String.t()} | {:error, :no_header} + def bearer(conn) do case Plug.Conn.get_req_header(conn, "authorization") do ["Bearer " <> key] when key != "" -> {:ok, String.trim(key)} _ -> {:error, :no_header} diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex new file mode 100644 index 00000000..ab778472 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/mishmar.ex @@ -0,0 +1,180 @@ +defmodule VavPipeline.BigdeiKodesh.Mishmar do + @moduledoc """ + The watch at each gate — how often one hand may knock. + + Bamidbar 18:4 — *v'shamru et mishmeret ohel mo'ed*: the Levites keep + the watch of the tent. The watch does not decide who may enter (that + is the Gate); it decides how often. The day's manna is one portion for + everyone who comes; without a watch one caller eats it all before noon. + + Tamid §1.1 fixes the pe'ah: 10 requests a minute and 100 an hour per + IP, and "no identification stored — IP is used for rate-limiting only, + not logged beyond the current request." For the courtyard and the + chatzer the Tamid says only that 429 exists and carries the manna + explanation; the numbers here come from Construction §2.3 (peak + korbanot per hour 200; five Sanhedrin panels at once) and are + configuration, not law — `config :vav_pipeline, :rate_limits`. + + **What is kept.** Two fixed windows per subject — the current minute + and the current hour — as counters in an ETS table. The subject is + not the IP and not the key: it is `sha256(salt <> subject)` with a + salt drawn at boot and never written anywhere. The table cannot be + read back into a list of who called; it cannot survive a restart; + the sweep drops a window a few minutes after it closes. This is the + Tamid's "not logged beyond the current request", kept in memory too. + + **What is answered.** `{:ok, remaining_this_minute}` or + `{:error, window, retry_after_seconds}`. The router turns the latter + into 429 with `Retry-After`. + """ + + use GenServer + require Logger + + @table __MODULE__ + @sweep_ms :timer.minutes(1) + + @type tier :: :peah | :courtyard | :chatzer + @type subject :: binary() + + @default_limits [ + # Tamid §1.1, verbatim. + peah: [minute: 10, hour: 100], + # Construction §2.3: 200 korbanot an hour at peak — one key may not + # be the whole peak. 20 a minute lets a client work; 200 an hour is + # the system's own ceiling. + courtyard: [minute: 20, hour: 200], + # Construction §2.3: five panels at once, deliberation measured in + # minutes. A key convening more than five cases a minute is not a + # court, it is a loop. + chatzer: [minute: 5, hour: 60] + ] + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) + end + + @doc "The configured limits for a tier, `[minute: n, hour: n]`." + @spec limits(tier()) :: keyword() + def limits(tier) do + configured = Application.get_env(:vav_pipeline, :rate_limits, []) + Keyword.get(configured, tier) || Keyword.fetch!(@default_limits, tier) + end + + @doc """ + Count one knock by `subject` at `tier`. + + Returns `{:ok, remaining}` (knocks left this minute) or + `{:error, :minute | :hour, retry_after_seconds}`. A refused knock is + not counted: a caller who keeps knocking is not punished further, but + is not let in either. + + When the watch is not running (tests without the supervisor) every + knock is allowed — and logged once as MISHMAR_ABSENT, because that is + not a state production may be in. + """ + @spec knock(tier(), subject(), integer()) :: + {:ok, non_neg_integer()} | {:error, :minute | :hour, pos_integer()} + def knock(tier, subject, now \\ System.os_time(:second)) when is_binary(subject) do + case :ets.whereis(@table) do + :undefined -> + absent() + {:ok, 0} + + _ -> + tier_limits = limits(tier) + per_minute = Keyword.fetch!(tier_limits, :minute) + per_hour = Keyword.fetch!(tier_limits, :hour) + id = hashed(tier, subject) + minute = div(now, 60) + hour = div(now, 3600) + + minute_count = :ets.lookup_element(@table, {id, :minute, minute}, 2, 0) + hour_count = :ets.lookup_element(@table, {id, :hour, hour}, 2, 0) + + cond do + hour_count >= per_hour -> + {:error, :hour, (hour + 1) * 3600 - now} + + minute_count >= per_minute -> + {:error, :minute, (minute + 1) * 60 - now} + + true -> + :ets.update_counter(@table, {id, :minute, minute}, {2, 1}, {{id, :minute, minute}, 0}) + :ets.update_counter(@table, {id, :hour, hour}, {2, 1}, {{id, :hour, hour}, 0}) + {:ok, per_minute - minute_count - 1} + end + end + end + + @doc "The subject for a request at the pe'ah: its peer address, as text." + @spec peer(Plug.Conn.t()) :: subject() + def peer(%Plug.Conn{remote_ip: ip}) do + case :inet.ntoa(ip) do + {:error, _} -> inspect(ip) + chars -> List.to_string(chars) + end + end + + @doc "Forget every window. For tests." + def reset(server \\ __MODULE__), do: GenServer.call(server, :reset) + + @doc "Drop the windows that have closed. Runs on its own every minute; callable for tests." + def sweep(server \\ __MODULE__, now \\ System.os_time(:second)), do: GenServer.call(server, {:sweep, now}) + + # ------------------------------------------------------------ + + @impl true + def init(_opts) do + :ets.new(@table, [:named_table, :public, :set, write_concurrency: true, read_concurrency: true]) + # The salt lives only in this process's state and in the (private) + # persistent key below. It is never logged, never written, and is + # different after every restart. + salt = :crypto.strong_rand_bytes(32) + :persistent_term.put({__MODULE__, :salt}, salt) + Process.send_after(self(), :sweep, @sweep_ms) + {:ok, %{}} + end + + @impl true + def handle_call(:reset, _from, state) do + :ets.delete_all_objects(@table) + {:reply, :ok, state} + end + + def handle_call({:sweep, now}, _from, state) do + do_sweep(now) + {:reply, :ok, state} + end + + @impl true + def handle_info(:sweep, state) do + do_sweep(System.os_time(:second)) + Process.send_after(self(), :sweep, @sweep_ms) + {:noreply, state} + end + + # A minute window is dead once the minute after it has passed; an + # hour window once the hour after it has. Nothing older is kept. + defp do_sweep(now) do + minute = div(now, 60) + hour = div(now, 3600) + + :ets.select_delete(@table, [ + {{{:_, :minute, :"$1"}, :_}, [{:<, :"$1", minute - 1}], [true]}, + {{{:_, :hour, :"$1"}, :_}, [{:<, :"$1", hour - 1}], [true]} + ]) + end + + defp hashed(tier, subject) do + salt = :persistent_term.get({__MODULE__, :salt}) + :crypto.hash(:sha256, [salt, Atom.to_string(tier), 0, subject]) + end + + defp absent do + unless :persistent_term.get({__MODULE__, :absent_logged}, false) do + :persistent_term.put({__MODULE__, :absent_logged}, true) + Logger.warning("MISHMAR_ABSENT: the watch is not running — no rate limit is applied") + end + end +end diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 26e2ecea..35abc48f 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -28,7 +28,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do require Logger - alias VavPipeline.BigdeiKodesh.Gate + alias VavPipeline.BigdeiKodesh.{Gate, Mishmar} # Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger # replaces Plug.Logger; sanitized paths on /peah/* honor the @@ -63,17 +63,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do end post "/peah/ask" do - case conn.body_params do - %{"question" => question} when is_binary(question) and question != "" -> - process_peah(conn, question) + # Tamid §1.1: 10 a minute, 100 an hour, per IP; the IP is used for + # this and nothing else (Mishmar keeps a salted hash, not the IP). + under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn -> + case conn.body_params do + %{"question" => question} when is_binary(question) and question != "" -> + process_peah(conn, question) - _ -> - send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{ - issue: "Missing or empty 'question' field", - correction: "Submit JSON with a 'question' string", - resubmit: true - }) - end + _ -> + send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{ + issue: "Missing or empty 'question' field", + correction: "Submit JSON with a 'question' string", + resubmit: true + }) + end + end) end # ============================================================ @@ -129,6 +133,32 @@ defmodule VavPipeline.BigdeiKodesh.Router do }) end + # The watch (Mishmar): how often one subject may knock at a tier. + # 429 carries the manna explanation Tamid §1.1 asks for, and Retry-After. + defp under_the_watch(conn, tier, subject, serve) do + case Mishmar.knock(tier, subject) do + {:ok, _remaining} -> + serve.(conn) + + {:error, window, retry_after} -> + limits = Mishmar.limits(tier) + + conn + |> put_resp_header("retry-after", Integer.to_string(retry_after)) + |> send_error(429, "RATE_LIMITED", %{ + issue: + "Too many requests this #{window}: the #{tier} allows " <> + "#{limits[:minute]} a minute and #{limits[:hour]} an hour per caller", + correction: "Wait #{retry_after}s and resubmit", + manna: + "The day's provision is one portion for everyone who comes (Shemot 16:18 — " <> + "the one who gathered much had nothing over). The watch keeps one caller " <> + "from gathering the whole omer.", + resubmit: true + }) + end + end + # === COURTYARD — Authenticated === # # The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate), @@ -136,7 +166,10 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp through_the_gate(conn, tier, serve) do case Gate.admit(conn, tier) do {:ok, _granted} -> - serve.(conn) + # Past the gate, the key is the hand that knocks: the watch + # counts per key, not per IP. A refused key never reaches it. + {:ok, key} = Gate.bearer(conn) + under_the_watch(conn, tier, key, serve) {:error, :tier_too_low} -> send_error(conn, 403, "FORBIDDEN", %{ @@ -951,6 +984,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp error_type("HALT" <> _), do: "cardinal_sin_detected" defp error_type("UNAUTHORIZED"), do: "authentication_required" defp error_type("FORBIDDEN"), do: "insufficient_tier" + defp error_type("RATE_LIMITED"), do: "too_many_requests" defp error_type("TEIKU" <> _), do: "unresolvable" defp error_type("LO_TOV"), do: "specification_mismatch" defp error_type("CHATAT" <> _), do: "correction_needed" diff --git a/vav_pipeline/lib/vav_pipeline/doorpost.ex b/vav_pipeline/lib/vav_pipeline/doorpost.ex index 62277e74..6058537b 100644 --- a/vav_pipeline/lib/vav_pipeline/doorpost.ex +++ b/vav_pipeline/lib/vav_pipeline/doorpost.ex @@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do # changes; the update is a deliberate operator commit. A bare # hash-update commit (without a corresponding spec change in the # same commit) is itself a drift signal. - @expected_hash "42b38626c6e2bd3e6fe10ed7f4cb34ee8dae118205379b7d822f278fe7eaa110" + @expected_hash "e4e3d56de7422280b5562c4a7ebce02fe05225a938bbce26d6e52f26b6b240eb" @doc "Return the list of files that constitute the category-grade specification." @spec files() :: [String.t()] diff --git a/vav_pipeline/test/fixtures/manna/ledger.json b/vav_pipeline/test/fixtures/manna/ledger.json deleted file mode 100644 index b034a61c..00000000 --- a/vav_pipeline/test/fixtures/manna/ledger.json +++ /dev/null @@ -1 +0,0 @@ -{"consumed":{"api_calls_external":0,"compute_units":3,"memory_mb":0,"sanhedrin_panels_max":0,"storage_delta_mb":0},"cycle_date":"2026-10-06","written_at":"2026-10-06T15:15:24.042079Z"} \ No newline at end of file diff --git a/vav_pipeline/test/tablet46_mishmar_test.exs b/vav_pipeline/test/tablet46_mishmar_test.exs new file mode 100644 index 00000000..1ae0ab01 --- /dev/null +++ b/vav_pipeline/test/tablet46_mishmar_test.exs @@ -0,0 +1,135 @@ +defmodule VavPipeline.Tablet46MishmarTest do + @moduledoc """ + Tablet 46 (2026-10-06) — no gate had a watch. + + Tamid §1.1 writes the pe'ah's limit in the spec; nothing enforced it, + on any tier. Every admitted request spends manna; one caller could + gather the whole omer. + """ + + use ExUnit.Case, async: false + + import Plug.Test + import Plug.Conn + + alias VavPipeline.BigdeiKodesh.{Mishmar, Router} + + @opts Router.init([]) + + setup do + original = Application.get_env(:vav_pipeline, :rate_limits) + + Application.put_env(:vav_pipeline, :rate_limits, + peah: [minute: 3, hour: 5], + courtyard: [minute: 2, hour: 100], + chatzer: [minute: 10_000, hour: 100_000] + ) + + Mishmar.reset() + + on_exit(fn -> + Application.put_env(:vav_pipeline, :rate_limits, original) + Mishmar.reset() + end) + + :ok + end + + describe "Mishmar.knock/3" do + test "counts the minute, then the hour, per subject; a refusal is not counted" do + t = 1_000_000 * 60 + + assert {:ok, 2} = Mishmar.knock(:peah, "a", t) + assert {:ok, 1} = Mishmar.knock(:peah, "a", t + 1) + assert {:ok, 0} = Mishmar.knock(:peah, "a", t + 2) + assert {:error, :minute, 57} = Mishmar.knock(:peah, "a", t + 3) + assert {:error, :minute, 57} = Mishmar.knock(:peah, "a", t + 3) + + # Another subject is another count. + assert {:ok, 2} = Mishmar.knock(:peah, "b", t + 3) + + # The next minute opens — but the hour (5) is nearly spent: 3 so far. + assert {:ok, 2} = Mishmar.knock(:peah, "a", t + 60) + assert {:ok, 1} = Mishmar.knock(:peah, "a", t + 61) + assert {:error, :hour, retry} = Mishmar.knock(:peah, "a", t + 62) + assert retry == 3600 - rem(t + 62, 3600) + + # The next hour opens. + assert {:ok, 2} = Mishmar.knock(:peah, "a", t + 3600) + end + + test "tiers are counted apart" do + t = 2_000_000 * 60 + assert {:ok, 1} = Mishmar.knock(:courtyard, "k", t) + assert {:ok, 0} = Mishmar.knock(:courtyard, "k", t) + assert {:error, :minute, _} = Mishmar.knock(:courtyard, "k", t) + assert {:ok, 2} = Mishmar.knock(:peah, "k", t) + end + + test "the sweep drops closed windows and nothing else" do + t = 3_000_000 * 60 + assert {:ok, _} = Mishmar.knock(:peah, "old", t) + assert {:ok, _} = Mishmar.knock(:peah, "new", t + 7200) + :ok = Mishmar.sweep(Mishmar, t + 7200) + + # "old" has been forgotten entirely; "new" is still counted. + assert :ets.info(Mishmar, :size) == 2 + assert {:ok, 1} = Mishmar.knock(:peah, "new", t + 7200) + end + + test "the table holds hashes, never the subject" do + :ok = Mishmar.reset() + {:ok, _} = Mishmar.knock(:peah, "203.0.113.7") + + for {{id, _, _}, _} <- :ets.tab2list(Mishmar) do + assert is_binary(id) and byte_size(id) == 32 + refute id =~ "203.0.113" + end + end + end + + describe "at the router" do + test "the pe'ah: the fourth knock from one address in a minute is 429 with Retry-After and the manna explanation" do + statuses = for _ <- 1..4, do: ask({203, 0, 113, 9}).status + assert Enum.take(statuses, 3) |> Enum.all?(&(&1 != 429)) + + conn = ask({203, 0, 113, 9}) + assert conn.status == 429 + [retry] = get_resp_header(conn, "retry-after") + assert String.to_integer(retry) in 1..60 + body = Jason.decode!(conn.resp_body) + assert body["error"]["code"] == "RATE_LIMITED" + assert body["error"]["guidance"]["manna"] =~ "Shemot 16:18" + + # Another address is not held back by this one. + refute ask({203, 0, 113, 10}).status == 429 + end + + test "the courtyard: per key, and only past the gate" do + # A wrong key knocks all it likes and is never counted — 401, not 429. + for _ <- 1..5, do: assert(bedikah("Bearer wrong").status == 401) + + assert bedikah("Bearer test-token").status != 429 + assert bedikah("Bearer test-token").status != 429 + conn = bedikah("Bearer test-token") + assert conn.status == 429 + assert Jason.decode!(conn.resp_body)["error"]["guidance"]["issue"] =~ "courtyard allows 2 a minute" + + # The chatzer key is its own hand, even at the same door. + refute bedikah("Bearer chatzer-test-token").status == 429 + end + end + + defp ask(ip) do + %{conn(:post, "/peah/ask", Jason.encode!(%{question: "x"})) | remote_ip: ip} + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + end + + defp bedikah(auth) do + conn(:post, "/courtyard/bedikah", Jason.encode!(%{output: "", model: "gpt"})) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", auth) + |> Router.call(@opts) + end +end