Tablet 45: the courtyard gate checks the key, not the shape of the header

/courtyard/* and /chatzer/* admitted any `Authorization: Bearer <x>`.
Every test sent `Bearer test-token`; none sent a wrong one. Every
admitted request spends the day's manna.

VavPipeline.BigdeiKodesh.Gate: keys are a file (Construction §4.6 —
never env, never config, never git), one `tier key` per line, tiers
courtyard < chatzer (the inner ring opens the outer). Constant-time
compare against every line; no file / empty / unreadable → nobody
passes, GATE_NO_KEYS in the log, the caller told only "missing or
invalid". 403 FORBIDDEN for an outer key at an inner gate. Read on
every request so a rotated key is live in ~1 min without a rollout.

Deploy: Secret shorashim-api-keys projected at
/etc/shorashim/secrets/api_keys (vav-pipeline only, 0440),
API_KEYS_FILE in the configmap, template + issue/rotate recipe in
secrets.yaml and KOHEN.md "Issuing a key". Spec: tamid §1.1 (gate,
403), vayakhel §4.6. Doorpost 91d09d8d… → 42b38626….
Tests: tablet45_gate_test (parse, tiers, wrong/near/empty/no key, no
file) — Elixir 527/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-06 15:16:33 +00:00
1 parent 996a8efe68
commit 79eb34840f
17 files changed
+372 -34

No files matched your search

+23
View File
@@ -488,6 +488,29 @@ curl https://shorashim.ch/kodesh/status | jq .halt_status
curl https://shorashim.ch/kodesh/status | jq '.refuge_components' curl https://shorashim.ch/kodesh/status | jq '.refuge_components'
``` ```
### Issuing a key — *Mishmeret HaSha'ar*
The courtyard and the chatzer open only to a key you issued. Keys live
in the `shorashim-api-keys` Secret, mounted as a file; the gate reads
the file on every request, so a change takes effect within about a
minute, without a rollout. One line per key: `courtyard <key>` (offerings,
bedikot) or `chatzer <key>` (also the Sanhedrin; opens the courtyard too).
```bash
umask 077; TMP=$(mktemp)
kubectl get secret shorashim-api-keys -n shorashim -o jsonpath='{.data.api_keys}' \
| base64 -d > "$TMP" 2>/dev/null || true # existing keys, if any
printf 'courtyard %s\n' "$(openssl rand -hex 32)" >> "$TMP"
kubectl create secret generic shorashim-api-keys -n shorashim \
--from-file=api_keys="$TMP" --dry-run=client -o yaml | kubectl apply -f -
tail -1 "$TMP" # hand this line's key to its bearer, then
rm -f "$TMP"
```
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).
### Checking a model's output — *Bedikah* ### Checking a model's output — *Bedikah*
The external check (Tamid §1.1, *lo taaseh lekha* §9.1). Hand it any The external check (Tamid §1.1, *lo taaseh lekha* §9.1). Hand it any
+5
View File
@@ -20,6 +20,11 @@ data:
# shared tmp-shorashim volume. Pod-internal; never on the network. # shared tmp-shorashim volume. Pod-internal; never on the network.
KORBAN_SOCKET_PATH: "/tmp/shorashim/korban.sock" KORBAN_SOCKET_PATH: "/tmp/shorashim/korban.sock"
# Courtyard / chatzer keys — a FILE (Construction §4.6), projected from
# the shorashim-api-keys Secret (secrets.yaml). The path is config; the
# keys never are.
API_KEYS_FILE: "/etc/shorashim/secrets/api_keys"
# Shoresh Engine binary — staged onto the shared shoresh-bin volume # Shoresh Engine binary — staged onto the shared shoresh-bin volume
# by the shoresh-engine-install init container (see deployment.yaml). # by the shoresh-engine-install init container (see deployment.yaml).
# vav_pipeline spawns it as an Erlang Port, i.e. as a child process of # vav_pipeline spawns it as an Erlang Port, i.e. as a child process of
+10
View File
@@ -76,6 +76,13 @@ spec:
# vav-pipeline, which spawns it as an Erlang Port. # vav-pipeline, which spawns it as an Erlang Port.
- name: shoresh-bin - name: shoresh-bin
emptyDir: {} emptyDir: {}
# The courtyard keys, as a file (Construction §4.6). Only
# vav-pipeline mounts it. Absent → the pod waits at
# ContainerCreating, loudly; there is no "open gate" fallback.
- name: api-keys
secret:
secretName: shorashim-api-keys
defaultMode: 0440
# --- POD SECURITY CONTEXT --- # --- POD SECURITY CONTEXT ---
# Every image runs as the non-root user `shorashim` (uid/gid # Every image runs as the non-root user `shorashim` (uid/gid
@@ -221,6 +228,9 @@ spec:
# directory both live under it. # directory both live under it.
- name: tmp-shorashim - name: tmp-shorashim
mountPath: /tmp mountPath: /tmp
- name: api-keys
mountPath: /etc/shorashim/secrets
readOnly: true
- name: shoresh-bin - name: shoresh-bin
mountPath: /opt/shoresh mountPath: /opt/shoresh
readOnly: true readOnly: true
+35
View File
@@ -88,3 +88,38 @@ type: Opaque
stringData: stringData:
# ecto://avnadmin:PASSWORD@host:port/database?sslmode=require # ecto://avnadmin:PASSWORD@host:port/database?sslmode=require
DATABASE_URL: "REPLACE_WITH_AVNADMIN_DATABASE_URL" DATABASE_URL: "REPLACE_WITH_AVNADMIN_DATABASE_URL"
---
# ============================================================
# shorashim-api-keys — who may enter the courtyard
# ============================================================
#
# Mounted as the file /etc/shorashim/secrets/api_keys in vav-pipeline
# (Construction §4.6: a secret is a file). One key per line:
#
# courtyard <key> offerings and bedikot (/courtyard/*)
# chatzer <key> also the Sanhedrin (/chatzer/*); opens the courtyard too
#
# Issue a key (KOHEN.md "Issuing a key"):
#
# umask 077; TMP=$(mktemp)
# printf 'courtyard %s\n' "$(openssl rand -hex 32)" >> "$TMP"
# kubectl create secret generic shorashim-api-keys -n shorashim \
# --from-file=api_keys="$TMP"
# # rotate: kubectl create ... --dry-run=client -o yaml | kubectl apply -f -
# rm -f "$TMP"
#
# The kubelet refreshes a projected Secret within about a minute; the
# gate reads the file on every request, so a rotated key takes effect
# without a rollout. No file, no keys → nobody passes (GATE_NO_KEYS in
# the log).
apiVersion: v1
kind: Secret
metadata:
name: shorashim-api-keys
namespace: shorashim
type: Opaque
stringData:
api_keys: |
# tier key
courtyard REPLACE_WITH_openssl_rand_hex_32
+11
View File
@@ -53,6 +53,15 @@ Rate limit: 10 requests per minute per IP
#### Machaneh Leviyah — Authenticated Endpoint #### Machaneh Leviyah — Authenticated Endpoint
**The gate.** A key is a line in the keys file
(`/etc/shorashim/secrets/api_keys`, Construction §4.6 — a file, never an
environment variable), `tier key`, tier `courtyard` or `chatzer`. The
key is compared in constant time against every line; a `chatzer` key
opens the courtyard too, a `courtyard` key at the chatzer is 403. No
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.
``` ```
Endpoint: POST /courtyard/offering Endpoint: POST /courtyard/offering
@@ -94,6 +103,7 @@ Response:
Errors: Errors:
401 — Invalid or missing API key 401 — Invalid or missing API key
403 — A key for an outer ring at an inner gate
422 — Input failed kiyor (validation). Body includes purification guidance. 422 — Input failed kiyor (validation). Body includes purification guidance.
429 — Rate limit exceeded. Body includes manna explanation. 429 — Rate limit exceeded. Body includes manna explanation.
503 — System in Shabbat/festival mode. Body includes resumption time. 503 — System in Shabbat/festival mode. Body includes resumption time.
@@ -162,6 +172,7 @@ Response (200):
Errors: Errors:
401 — Invalid or missing API key 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 422 — Missing or empty output, output over 65,536 bytes, unknown model
503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE, 503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE,
TALMUDIC_UNAVAILABLE. Body says which; resubmit: true. TALMUDIC_UNAVAILABLE. Body says which; resubmit: true.
+1
View File
@@ -629,6 +629,7 @@ table inet filter {
# /etc/shorashim/secrets/ (mode 0600, owned by shorashim user) # /etc/shorashim/secrets/ (mode 0600, owned by shorashim user)
├── db_password # PostgreSQL password ├── db_password # PostgreSQL password
├── claude_api_key # Anthropic API key for korban source ├── claude_api_key # Anthropic API key for korban source
├── api_keys # Courtyard/chatzer keys, one "tier key" per line (Tamid §1.1)
├── l1_hash # Expected SHA-256 of L1 filesystem ├── l1_hash # Expected SHA-256 of L1 filesystem
└── kohanim_ssh_keys/ # Authorized keys for kohanim (operators) SSH └── kohanim_ssh_keys/ # Authorized keys for kohanim (operators) SSH
``` ```
+4 -1
View File
@@ -17,7 +17,10 @@ config :vav_pipeline,
shoresh_engine_cwd: nil, shoresh_engine_cwd: nil,
# Talmudic Engine (Prolog) — Unix domain socket path # Talmudic Engine (Prolog) — Unix domain socket path
talmudic_socket_path: "/tmp/shorashim/talmudic.sock", talmudic_socket_path: "/tmp/shorashim/talmudic.sock",
korban_socket_path: "/tmp/shorashim/korban.sock" 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
# L3 — Oral Tradition (PostgreSQL) # L3 — Oral Tradition (PostgreSQL)
# Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE. # Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE.
+2
View File
@@ -30,6 +30,8 @@ if config_env() == :prod do
System.get_env("TALMUDIC_SOCKET_PATH") || "/tmp/shorashim/talmudic.sock", System.get_env("TALMUDIC_SOCKET_PATH") || "/tmp/shorashim/talmudic.sock",
korban_socket_path: korban_socket_path:
System.get_env("KORBAN_SOCKET_PATH") || "/tmp/shorashim/korban.sock", System.get_env("KORBAN_SOCKET_PATH") || "/tmp/shorashim/korban.sock",
# The keys themselves are in the file (a mounted Secret), not in env.
api_keys_file: System.get_env("API_KEYS_FILE") || "/etc/shorashim/secrets/api_keys",
# Shoresh Engine binary — VavPipeline.ShoreshEnginePort opens an # Shoresh Engine binary — VavPipeline.ShoreshEnginePort opens an
# Erlang Port with {:spawn_executable, binary}, which spawns a CHILD # Erlang Port with {:spawn_executable, binary}, which spawns a CHILD
# PROCESS OF THIS CONTAINER. A Port cannot cross a container # PROCESS OF THIS CONTAINER. A Port cannot cross a container
+1
View File
@@ -6,6 +6,7 @@ config :logger, level: :warning
config :vav_pipeline, config :vav_pipeline,
l1_mount_point: "../l1", l1_mount_point: "../l1",
manna_provision_file: "test/fixtures/manna/today.json", manna_provision_file: "test/fixtures/manna/today.json",
api_keys_file: "test/fixtures/api_keys",
cycle_boundary_check_interval_ms: :timer.hours(24), cycle_boundary_check_interval_ms: :timer.hours(24),
http_port: 0 http_port: 0
@@ -0,0 +1,130 @@
defmodule VavPipeline.BigdeiKodesh.Gate do
@moduledoc """
The gate of the courtyard — who may bring an offering past the pe'ah.
Bamidbar 1:51 — *v'hazar hakarev yumat*: the stranger who draws near
the inner service does not pass. The pe'ah (`/peah/*`) is open to the
ger; the courtyard (`/courtyard/*`) and the chatzer (`/chatzer/*`) are
for those who were given a key.
Until 2026-10-06 the gate looked only at the SHAPE of the header: any
`Authorization: Bearer <anything>` was admitted. A gate that opens for
every hand is a wall with a hole in it, and every admitted request
spends the day's manna.
**Keys.** A file (Construction §4.6: secrets are files, mode 0600,
never environment variables, never configuration, never the
repository), one key per line:
# tier key
courtyard 5f2c… # may bring offerings and bedikot
chatzer 9a1e… # may also convene the Sanhedrin
A chatzer key opens the courtyard too — the inner ring contains the
outer. Blank lines and `#` comments are ignored. The file is read on
every request so that a rotated key takes effect at once; it is a few
lines, and the cost is nothing beside a cycle.
**Fail closed.** No file, an empty file, an unreadable file — nobody
passes. The operator sees it in the log (`GATE_NO_KEYS`), never the
caller: the caller is told only that the key is missing or invalid.
Keys are compared in constant time (`Plug.Crypto.secure_compare/2`).
No key, valid or not, is ever written to a log.
"""
require Logger
@tiers [:courtyard, :chatzer]
@type tier :: :courtyard | :chatzer
@doc "Tiers, outermost first. A key of a later tier opens every earlier one."
@spec tiers() :: [tier()]
def tiers, do: @tiers
@doc "Where the keys are read from. `nil` means the gate is shut."
@spec keys_file() :: String.t() | nil
def keys_file, do: Application.get_env(:vav_pipeline, :api_keys_file)
@doc """
Admit the request to `tier`, or say why not.
* `{:ok, granted}` — the key is good for `granted`, which is `tier`
or an inner one.
* `{:error, :no_header}` — no `Authorization: Bearer <key>` header.
* `{:error, :unknown_key}` — the key is not in the file, or there
is no file (logged as GATE_NO_KEYS, once per denial).
* `{:error, :tier_too_low}` — a good key, but for an outer ring.
"""
@spec admit(Plug.Conn.t(), tier()) ::
{:ok, tier()} | {:error, :no_header | :unknown_key | :tier_too_low}
def admit(conn, tier) when tier in @tiers do
with {:ok, key} <- bearer(conn),
{:ok, granted} <- lookup(key, keys_file()) do
if rank(granted) >= rank(tier), do: {:ok, granted}, else: {:error, :tier_too_low}
end
end
@doc "Parse the contents of a keys file into `[{tier, key}]`. Malformed lines are skipped."
@spec parse(String.t()) :: [{tier(), String.t()}]
def parse(contents) do
contents
|> String.split(~r/\r?\n/)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#")))
|> Enum.flat_map(fn line ->
case String.split(line, ~r/\s+/, parts: 3) do
[tier, key | _] when tier in ["courtyard", "chatzer"] and key != "" ->
[{String.to_existing_atom(tier), key}]
_ ->
[]
end
end)
end
# ------------------------------------------------------------
defp bearer(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> key] when key != "" -> {:ok, String.trim(key)}
_ -> {:error, :no_header}
end
end
defp lookup(_key, nil) do
Logger.warning("GATE_NO_KEYS: no api_keys_file configured — the courtyard is shut")
{:error, :unknown_key}
end
defp lookup(key, path) do
case File.read(path) do
{:ok, contents} ->
entries = parse(contents)
if entries == [] do
Logger.warning("GATE_NO_KEYS: #{path} holds no keys — the courtyard is shut")
end
# Compare against every entry, not just until the first match:
# the time taken must not say how far down the file the key was.
entries
|> Enum.reduce(nil, fn {tier, candidate}, found ->
if Plug.Crypto.secure_compare(candidate, key), do: best(found, tier), else: found
end)
|> case do
nil -> {:error, :unknown_key}
tier -> {:ok, tier}
end
{:error, reason} ->
Logger.warning("GATE_NO_KEYS: cannot read #{path} (#{reason}) — the courtyard is shut")
{:error, :unknown_key}
end
end
defp best(nil, tier), do: tier
defp best(a, b), do: if(rank(a) >= rank(b), do: a, else: b)
defp rank(tier), do: Enum.find_index(@tiers, &(&1 == tier))
end
@@ -28,6 +28,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do
require Logger require Logger
alias VavPipeline.BigdeiKodesh.Gate
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger # Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
# replaces Plug.Logger; sanitized paths on /peah/* honor the # replaces Plug.Logger; sanitized paths on /peah/* honor the
# no-tracking promise made in /peah/ask's own response body. # no-tracking promise made in /peah/ask's own response body.
@@ -128,21 +130,35 @@ defmodule VavPipeline.BigdeiKodesh.Router do
end end
# === COURTYARD — Authenticated === # === COURTYARD — Authenticated ===
#
# The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate),
# not merely for its shape. Until 2026-10-06 any Bearer value passed.
defp through_the_gate(conn, tier, serve) do
case Gate.admit(conn, tier) do
{:ok, _granted} ->
serve.(conn)
post "/courtyard/offering" do {:error, :tier_too_low} ->
case get_req_header(conn, "authorization") do send_error(conn, 403, "FORBIDDEN", %{
["Bearer " <> _token] -> issue: "This key does not open the #{tier}",
process_courtyard(conn) correction: "Use a key issued for the #{tier} tier",
resubmit: false
})
_ -> {:error, _} ->
send_error(conn, 401, "UNAUTHORIZED", %{ send_error(conn, 401, "UNAUTHORIZED", %{
issue: "Missing or invalid Authorization header", issue: "Missing or invalid API key",
correction: "Include 'Authorization: Bearer <api-key>' header", correction: "Include 'Authorization: Bearer <api-key>' with a key issued by the kohen",
resubmit: true resubmit: true
}) })
end end
end end
post "/courtyard/offering" do
through_the_gate(conn, :courtyard, &process_courtyard/1)
end
# === COURTYARD — the bedikah (Construction §3.9) === # === COURTYARD — the bedikah (Construction §3.9) ===
# #
# An external model's output, brought by hand to be examined against # An external model's output, brought by hand to be examined against
@@ -152,33 +168,13 @@ defmodule VavPipeline.BigdeiKodesh.Router do
# engraved in L3. See VavPipeline.Bedikah. # engraved in L3. See VavPipeline.Bedikah.
post "/courtyard/bedikah" do post "/courtyard/bedikah" do
case get_req_header(conn, "authorization") do through_the_gate(conn, :courtyard, &process_bedikah/1)
["Bearer " <> _token] ->
process_bedikah(conn)
_ ->
send_error(conn, 401, "UNAUTHORIZED", %{
issue: "Missing or invalid Authorization header",
correction: "Include 'Authorization: Bearer <api-key>' header",
resubmit: true
})
end
end end
# === CHATZER — Full Access (Sanhedrin) === # === CHATZER — Full Access (Sanhedrin) ===
post "/chatzer/sanhedrin" do post "/chatzer/sanhedrin" do
case get_req_header(conn, "authorization") do through_the_gate(conn, :chatzer, &process_sanhedrin/1)
["Bearer " <> _token] ->
process_sanhedrin(conn)
_ ->
send_error(conn, 401, "UNAUTHORIZED", %{
issue: "Missing or invalid Authorization header",
correction: "Include 'Authorization: Bearer <api-key>' header",
resubmit: true
})
end
end end
# === KODESH — Operator Interface === # === KODESH — Operator Interface ===
@@ -954,6 +950,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
defp error_type("CLOUD" <> _), do: "system_transition" defp error_type("CLOUD" <> _), do: "system_transition"
defp error_type("HALT" <> _), do: "cardinal_sin_detected" defp error_type("HALT" <> _), do: "cardinal_sin_detected"
defp error_type("UNAUTHORIZED"), do: "authentication_required" defp error_type("UNAUTHORIZED"), do: "authentication_required"
defp error_type("FORBIDDEN"), do: "insufficient_tier"
defp error_type("TEIKU" <> _), do: "unresolvable" defp error_type("TEIKU" <> _), do: "unresolvable"
defp error_type("LO_TOV"), do: "specification_mismatch" defp error_type("LO_TOV"), do: "specification_mismatch"
defp error_type("CHATAT" <> _), do: "correction_needed" defp error_type("CHATAT" <> _), do: "correction_needed"
+1 -1
View File
@@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do
# changes; the update is a deliberate operator commit. A bare # changes; the update is a deliberate operator commit. A bare
# hash-update commit (without a corresponding spec change in the # hash-update commit (without a corresponding spec change in the
# same commit) is itself a drift signal. # same commit) is itself a drift signal.
@expected_hash "91d09d8dfb47f4440e5e02f8ef2695026fade315cfaadf7c72c126bad3903547" @expected_hash "42b38626c6e2bd3e6fe10ed7f4cb34ee8dae118205379b7d822f278fe7eaa110"
@doc "Return the list of files that constitute the category-grade specification." @doc "Return the list of files that constitute the category-grade specification."
@spec files() :: [String.t()] @spec files() :: [String.t()]
+1 -1
View File
@@ -342,7 +342,7 @@ defmodule VavPipeline.BedikahTest do
defp post_bedikah(body, opts \\ []) do defp post_bedikah(body, opts \\ []) do
conn = conn(:post, "/courtyard/bedikah", Jason.encode!(body)) |> put_req_header("content-type", "application/json") conn = conn(:post, "/courtyard/bedikah", Jason.encode!(body)) |> put_req_header("content-type", "application/json")
conn = if Keyword.get(opts, :auth, true), do: put_req_header(conn, "authorization", "Bearer test"), else: conn conn = if Keyword.get(opts, :auth, true), do: put_req_header(conn, "authorization", "Bearer test-token"), else: conn
Router.call(conn, @opts) Router.call(conn, @opts)
end end
+2 -2
View File
@@ -294,7 +294,7 @@ defmodule VavPipeline.BigdeiKodeshTest do
conn = conn =
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{not_case: "wrong field"})) conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{not_case: "wrong field"}))
|> put_req_header("content-type", "application/json") |> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer test-token") |> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts) |> Router.call(@opts)
assert conn.status == 422 assert conn.status == 422
@@ -306,7 +306,7 @@ defmodule VavPipeline.BigdeiKodeshTest do
case: "Is cooking on Shabbat permitted for pikuach nefesh?" case: "Is cooking on Shabbat permitted for pikuach nefesh?"
})) }))
|> put_req_header("content-type", "application/json") |> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer test-token") |> put_req_header("authorization", "Bearer chatzer-test-token")
|> Router.call(@opts) |> Router.call(@opts)
assert conn.status == 200 assert conn.status == 200
+6
View File
@@ -0,0 +1,6 @@
# Test keys for the gate (BigdeiKodesh.Gate). tier, whitespace, key.
courtyard test-token
chatzer chatzer-test-token
# A malformed line, ignored:
kodesh not-a-tier
+1
View File
@@ -0,0 +1 @@
{"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"}
+113
View File
@@ -0,0 +1,113 @@
defmodule VavPipeline.Tablet45GateTest do
@moduledoc """
Tablet 45 (2026-10-06) — the courtyard gate opened for any hand.
`/courtyard/*` and `/chatzer/*` checked that the Authorization header
had the SHAPE `Bearer <something>`, and nothing else. Every test of it
sent `Bearer test-token` and passed; none sent `Bearer wrong`.
These tests send the wrong key, no key, a key for the outer ring at
the inner gate, and a key when there is no keys file at all.
"""
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias VavPipeline.BigdeiKodesh.{Gate, Router}
@opts Router.init([])
describe "Gate.parse/1" do
test "reads tier and key, skips comments, blanks and unknown tiers" do
assert Gate.parse("""
# keys
courtyard abc
chatzer\tdef # trailing words are ignored
kodesh ghi
courtyard
""") == [courtyard: "abc", chatzer: "def"]
end
end
describe "Gate.admit/2" do
test "the right key for the ring" do
assert {:ok, :courtyard} = Gate.admit(with_key("test-token"), :courtyard)
assert {:ok, :chatzer} = Gate.admit(with_key("chatzer-test-token"), :chatzer)
end
test "the inner ring's key opens the outer gate; not the reverse" do
assert {:ok, :chatzer} = Gate.admit(with_key("chatzer-test-token"), :courtyard)
assert {:error, :tier_too_low} = Gate.admit(with_key("test-token"), :chatzer)
end
test "a wrong key, a near key, an empty key, no header" do
assert {:error, :unknown_key} = Gate.admit(with_key("wrong"), :courtyard)
assert {:error, :unknown_key} = Gate.admit(with_key("test-token2"), :courtyard)
assert {:error, :unknown_key} = Gate.admit(with_key("test-toke"), :courtyard)
assert {:error, :no_header} = Gate.admit(with_key(""), :courtyard)
assert {:error, :no_header} = Gate.admit(conn(:post, "/courtyard/offering"), :courtyard)
basic = conn(:post, "/courtyard/offering") |> put_req_header("authorization", "Basic dGVzdA==")
assert {:error, :no_header} = Gate.admit(basic, :courtyard)
end
test "no keys file, an empty one, a missing one — nobody passes" do
original = Application.get_env(:vav_pipeline, :api_keys_file)
on_exit(fn -> Application.put_env(:vav_pipeline, :api_keys_file, original) end)
Application.put_env(:vav_pipeline, :api_keys_file, nil)
assert {:error, :unknown_key} = Gate.admit(with_key("test-token"), :courtyard)
empty = Path.join(System.tmp_dir!(), "tablet45_empty_#{System.unique_integer([:positive])}")
File.write!(empty, "# nothing here\n\n")
Application.put_env(:vav_pipeline, :api_keys_file, empty)
assert {:error, :unknown_key} = Gate.admit(with_key("test-token"), :courtyard)
File.rm!(empty)
Application.put_env(:vav_pipeline, :api_keys_file, "/nonexistent/api_keys")
assert {:error, :unknown_key} = Gate.admit(with_key("test-token"), :courtyard)
end
end
describe "at the router" do
test "a wrong key is 401 at every gated door, and says nothing about the keys" do
for {path, body} <- [
{"/courtyard/offering", %{offering_type: "shelamim", content: "x"}},
{"/courtyard/bedikah", %{output: "x", model: "gpt"}},
{"/chatzer/sanhedrin", %{case: "x"}}
] do
conn = post(path, body, "Bearer wrong")
assert conn.status == 401, path
decoded = Jason.decode!(conn.resp_body)
assert decoded["error"]["code"] == "UNAUTHORIZED"
refute conn.resp_body =~ "test-token"
end
end
test "a courtyard key at the chatzer is 403, not 401" do
conn = post("/chatzer/sanhedrin", %{case: "x"}, "Bearer test-token")
assert conn.status == 403
assert Jason.decode!(conn.resp_body)["error"]["code"] == "FORBIDDEN"
end
test "a chatzer key is good in the courtyard" do
conn = post("/courtyard/offering", %{offering_type: "shelamim"}, "Bearer chatzer-test-token")
# Past the gate: the next check (no content) answers, not the gate.
assert conn.status == 422
end
end
defp with_key(key) do
conn(:post, "/courtyard/offering") |> put_req_header("authorization", "Bearer " <> key)
end
defp post(path, body, auth) do
conn(:post, path, Jason.encode!(body))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", auth)
|> Router.call(@opts)
end
end