Codemojex · C7.2 · a dive of The Live Surface on Phoenix
Eleven routes, two tiers, one facade.
The whole lifecycle rides eleven JSON routes under /api. Six are open — health, the handshake, the webhook, the lobby, the game view, the leaderboard — and read no caller-supplied identity. Five act as a player — join, guess, history, buy, convert — and read only conn.assigns.player, resolved from Bearer <SES> by the :auth plug. Every action calls the Codemojex facade through a privacy-safe view, and a guess returns accepted while the bus carries the scoring.
Grounding — echo/…/codemojex_web/controllers/game_controller.ex (join/2 · guess/2 · history/2 · leaderboard/2 · buy_keys/2 · convert/2) · router.ex (the two /api scopes) · auth.ex (the :auth plug) · lib/codemojex/view.ex · the privacy story.
pipe_through :api — no bearer, and no caller-supplied identity is read. The reads go through the privacy-safe views: lobby(), game_view(game), leaderboard(game, 20); the handshake is the sole SES mint (C7.1); the webhook carries the Stars callbacks (C6.2).The :auth pipeline is one plug. CodemojexWeb.Auth reads Authorization: Bearer <SES>, resolves the SES to a PLR through Codemojex.Session.resolve/1 — which also slides the TTL — and assigns conn.assigns.player, the only identity a player-acting action reads. A missing or malformed bearer, or an unknown / expired / revoked SES, halts the request with 401 %{error: :unauthenticated}.
The two tiers, one trust point
router.ex · auth.exrouter.ex splits /api into two scopes. The open scope carries the routes anyone may call — the health probe, the auth handshake, the Telegram webhook, and the three reads (the lobby, the game view, the leaderboard). None of them accepts an identity from the caller: the reads are shaped by Codemojex.View before a byte leaves the process, and identity enters the system at exactly one place, the handshake POST /api/auth/:platform — verified there, never trusted from a parameter.
The player-acting scope adds one pipeline step: pipe_through [:api, :auth]. The CodemojexWeb.Auth plug is the read-side trust point (cm.4): every request in this tier presents Authorization: Bearer <SES>, the plug resolves the session, and the controller action reads conn.assigns.player — a PLR id the server resolved, not a field the client posted. A guess, a join, a purchase, a conversion: each is keyed by that resolved identity.
There is no dev/test bypass in the plug: a leaked auth-skip minting access into the shared Valkey would fool every service, so the test suite mints a real SES through a test/support helper rather than a trust flag. Revocation is immediate — a revoked SES reads :unknown on the next request because :tracking evicted it from L1 and the DEL cleared L2. The handshake itself — the HMAC check on Telegram initData and the SES mint — is the previous dive, C7.1.
The retired route: the original surface minted players over an unauthenticated POST /api/players — free identity, and with it free starter money. cm.4 removed it; the handshake is now the sole SES mint, and every player-acting route sits behind the bearer.
Accepted and on its way
game_controller.ex · guess/2The controller is deliberately thin: every action is a facade call and a view. The centrepiece is guess/2 — the one route that moves money and work at once. It returns before any scoring happens, because the scoring is not its job:
def join(conn, %{"id" => room}) do
with {:ok, game} <- Codemojex.join_room(room, conn.assigns.player) do
json(conn, %{game: game, view: Codemojex.game_view(game)})
end
end
def guess(conn, %{"id" => game} = params) do
with {:ok, _job} <- Codemojex.submit(game, conn.assigns.player, params["emojis"]) do # the enqueued JOB — not a score
json(conn, %{status: "accepted", view: Codemojex.game_view(game)}) # the bus carries the scoring
end
end
Codemojex.submit/3 — the facade delegates to Codemojex.Guesses — admits the guess (the fee, the rate limit, the lane) and drops the scoring job on the bus. The {:ok, _job} is the enqueued JOB id, and the response carries %{status: "accepted", view: …} — no points anywhere in it. The points arrive later: the scoring worker computes the linear score (100 − 20·d per position, 600 for a perfect board) and the channel pushes scored to everyone watching the game (C7.3). The HTTP surface never blocks on the scoring.
The same one-shape holds across the tier. join/2 returns the GAM id plus the view; history/2 returns %{history: Codemojex.my_history(game, conn.assigns.player)}; leaderboard/2 maps the facade’s {player, score} pairs into %{player: p, score: s} rows. A with miss falls through to action_fallback CodemojexWeb.FallbackController, which shapes the error into JSON — the happy path is the only path a controller action spells out.
Accepted-and-on-its-way is the log discipline applied at the HTTP seam: the guess is appended, ordered, and durable before it is scored, so a burst of guesses costs the web tier nothing but enqueues. The response’s view is the same privacy-safe game_view/1 as the open read — a submit answers with exactly what a spectator could fetch.
The privacy-safe views
view.exEvery read the API serves goes through Codemojex.View, with the privacy invariant built in: nothing returns the secret, and nothing returns another player’s guesses. One predicate sets how wide the gate opens — a classic game (feedback = "score") is revealed from the first guess; a golden game (feedback = "none") stays unrevealed until its revealed_ms is set. Select a mode to read the exact field sets on the wire.
revealed?(r) is true from the first guess (feedback = "score"). game_view carries totals %{players, attempts, best, best_pct}; my_history rows are [:emojis, :points, :at_ms]; the leaderboard returns 2 rows of {player, score}. The secret appears in none of them.The narrowing is a field list, not a filter bolted on afterward — my_history/3 picks the columns before it maps a row out:
# A game's score is visible when it is not blind, or once it has revealed. defp revealed?(r) do Map.get(r, :feedback, "score") != "none" or not is_nil(Map.get(r, :revealed_ms)) end def my_history(game, player, n \\ 50) do fields = case Store.game(game) do r when is_map(r) -> if revealed?(r), do: [:emojis, :points, :at_ms], else: [:emojis, :at_ms] # points withheld pre-reveal _ -> [:emojis, :points, :at_ms] end game |> Store.guesses_for(player, n) # the player's OWN attempts, from Postgres |> Enum.map(&Map.take(&1, fields)) end
game_view/1 applies the same predicate at the map level. The base — the game, the room, the emojiset snapshot, ends_ms, prize_pool / prize_usd, guess_fee, free, status — is common to both modes. Revealed, totals is %{players, attempts, best, best_pct}. Golden pre-reveal, totals shrinks to %{players, attempts} — no best, no score on the wire — and the published commitment is exposed so the player records it for later verification; the preimage stays server-side until the sealed reveal. A classic game carries no commitment at all.
leaderboard/2 is the strictest read: {player, max_score} highest first, no guesses attached — and for a golden game, [] until reveal. There is no partial answer to leak an ordering from.
The privacy story pins the acceptance: the game view never carries the secret — no :secret key anywhere in the view, only the keyboard snapshot; a player’s history shows only their own attempts — Alice reads her own guess and nothing of Bob’s; the leaderboard exposes {player, score} pairs with no emojis attached. Three scenarios, each an ExUnit test driving the real game.
The key operations
buy_keys/2 · convert/2Two routes move money in. POST /api/keys/buy pattern-matches on exactly %{"package_id" => package_id, "rail" => rail} — the client names a package and a rail, nothing else. The key count, the price, and the payment ref are server-derived and pinned on the ORD at creation (Codemojex.create_order/3), so a tampered client cannot name its own price or key count; the catch-all clause answers anything else with {:error, :bad_request}. No keys are minted here — minting happens later, only through the OTX-gated settle_payment (C6.2). The response returns the order projected for the client — id, rail, keys, currency, price_minor, status, with no internal rate provenance — plus a Stars XTR invoice link when a bot token is configured.
POST /api/keys/convert coerces its diamonds parameter to an integer, calls Codemojex.convert_to_keys/2 (the facade delegates to the wallet), and returns the new balance. Diamonds won at settlement become keys to play with — money circulating inside the platform.
/api.
Where the buy settles — the rail pricing, the pinned order, exactly-once on the (rail, external_id) index — is C6; this page is where those operations meet a player’s bearer.
References
sources · relatedSources
- Pat Helland — Life Beyond Distributed Transactionsentities behind boundaries — the privacy seam the views enforce
- Telegram — Mini Appsthe Mini App client these JSON routes serve
- Jay Kreps — The Logaccepted-and-on-its-way: the ordered log the guess rides to the scorer
Related
- C7 — The Live Surface on Phoenixthe chapter hub
- C1.3 — The privacy boundarythe boundary the views implement
- C6 — The Revenue Ledger and the KeyShopwhere the key operations settle
- BCS — the architecture lawidentities, and messages about identities