Skip to content

Commit a8f3d80

Browse files
committed
Replace DataStore and live_data with GraphQL data layer and declarative actions
- Add GraphQL client (Req-based HTTP, introspection, SDL parsing, response caching with stampede protection, circuit breaker per endpoint, operation allowlist) - Add declarative action primitives (28 action types: navigate, patch, redirect, submit, fetch, set_state, show/hide/toggle, flash, conditional, sequence, custom escape hatch) - Add HMAC signing for action documents, Cloak encryption for API keys - Add server-side cache invalidation API and template push endpoints - Migration V008: beacon_graphql_endpoints, beacon_page_queries tables, event_handler format/actions fields - Remove DataStore, live_data, LiveDataAssign entirely (no backward compatibility) - Update PageRenderCache to track graphql_endpoint dependencies - Update snippet system: template variable key is now 'data' instead of 'live_data'
1 parent ae9c9d8 commit a8f3d80

63 files changed

Lines changed: 2930 additions & 2321 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/test.exs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ config :beacon, Beacon.BeaconTest.Repo,
1414
stacktrace: true,
1515
show_sensitive_data_on_connection_error: true
1616

17+
config :beacon, Beacon.Vault,
18+
ciphers: [
19+
default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: Base.decode64!("fInEp/HOb64urC4UcPVHNZb8+qYx8QQ0REAloYqKxWs=")}
20+
]
21+
1722
config :beacon,
1823
session_options: [
1924
store: :cookie,

lib/beacon/actions/interpreter.ex

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
defmodule Beacon.Actions.Interpreter do
2+
@moduledoc false
3+
4+
import Phoenix.Component, only: [assign: 3], warn: false
5+
import Phoenix.LiveView, only: [push_navigate: 2, push_patch: 2, redirect: 2, push_event: 3, put_flash: 3], warn: false
6+
7+
alias Beacon.GraphQL.Client
8+
9+
require Logger
10+
11+
@max_steps 100
12+
13+
@doc """
14+
Execute an action document against a LiveView socket.
15+
16+
Returns `{:noreply, socket}`.
17+
"""
18+
@spec execute(map(), map(), Phoenix.LiveView.Socket.t()) ::
19+
{:noreply, Phoenix.LiveView.Socket.t()}
20+
def execute(%{"version" => 1, "steps" => steps}, event_params, socket) when is_list(steps) do
21+
ctx = %{
22+
socket: socket,
23+
event_params: event_params,
24+
state: %{},
25+
step_count: 0,
26+
fetch_results: %{},
27+
site: socket.assigns.beacon.site
28+
}
29+
30+
ctx = execute_steps(steps, ctx)
31+
{:noreply, ctx.socket}
32+
end
33+
34+
def execute(%{"steps" => steps}, event_params, socket) when is_list(steps) do
35+
# Accept documents without explicit version (default to v1)
36+
execute(%{"version" => 1, "steps" => steps}, event_params, socket)
37+
end
38+
39+
def execute(invalid, _event_params, _socket) do
40+
raise Beacon.Web.ServerError,
41+
"invalid action document: expected %{\"version\" => 1, \"steps\" => [...]}, got: #{inspect(invalid)}"
42+
end
43+
44+
defp execute_steps(steps, ctx) do
45+
Enum.reduce_while(steps, ctx, fn step, ctx ->
46+
if ctx.step_count >= @max_steps do
47+
Logger.error("[Beacon.Actions] Step limit (#{@max_steps}) exceeded, aborting")
48+
{:halt, ctx}
49+
else
50+
ctx = %{ctx | step_count: ctx.step_count + 1}
51+
{:cont, execute_step(step, ctx)}
52+
end
53+
end)
54+
end
55+
56+
# -- Navigation --
57+
58+
defp execute_step(%{"action" => "navigate", "to" => to}, ctx) do
59+
%{ctx | socket: push_navigate(ctx.socket, to: resolve_value(to, ctx))}
60+
end
61+
62+
defp execute_step(%{"action" => "patch", "to" => to}, ctx) do
63+
%{ctx | socket: push_patch(ctx.socket, to: resolve_value(to, ctx))}
64+
end
65+
66+
defp execute_step(%{"action" => "redirect", "to" => to}, ctx) do
67+
%{ctx | socket: redirect(ctx.socket, to: resolve_value(to, ctx))}
68+
end
69+
70+
defp execute_step(%{"action" => "open_url", "url" => url}, ctx) do
71+
%{ctx | socket: push_event(ctx.socket, "beacon:open_url", %{url: resolve_value(url, ctx)})}
72+
end
73+
74+
defp execute_step(%{"action" => "scroll_to", "target" => target}, ctx) do
75+
%{ctx | socket: push_event(ctx.socket, "beacon:scroll_to", %{target: target})}
76+
end
77+
78+
defp execute_step(%{"action" => "dismiss"}, ctx) do
79+
%{ctx | socket: push_event(ctx.socket, "beacon:dismiss", %{})}
80+
end
81+
82+
# -- Data (GraphQL) --
83+
84+
defp execute_step(%{"action" => action, "endpoint" => endpoint} = step, ctx)
85+
when action in ["submit", "fetch"] do
86+
query = step["query"] || step["operation"]
87+
variables = resolve_variables(step["variables"] || %{}, ctx)
88+
result_key = step["result"]
89+
90+
case Client.execute(ctx.site, endpoint, query, variables) do
91+
{:ok, data} ->
92+
ctx = if result_key, do: put_in(ctx, [:fetch_results, result_key], data), else: ctx
93+
execute_branch(step["on_success"], ctx)
94+
95+
{:partial, data, _errors} ->
96+
ctx = if result_key, do: put_in(ctx, [:fetch_results, result_key], data), else: ctx
97+
execute_branch(step["on_success"], ctx)
98+
99+
{:error, reason} ->
100+
Logger.warning("[Beacon.Actions] #{action} failed: #{inspect(reason)}")
101+
ctx = %{ctx | state: Map.put(ctx.state, "error", %{"message" => inspect(reason)})}
102+
execute_branch(step["on_error"], ctx)
103+
end
104+
end
105+
106+
# -- State --
107+
108+
defp execute_step(%{"action" => "set_state", "key" => key, "value" => value}, ctx) do
109+
resolved = resolve_value(value, ctx)
110+
assign_key = String.to_atom(key)
111+
ctx = %{ctx | state: Map.put(ctx.state, key, resolved)}
112+
%{ctx | socket: assign(ctx.socket, assign_key, resolved)}
113+
end
114+
115+
defp execute_step(%{"action" => "toggle_state", "key" => key}, ctx) do
116+
current = Map.get(ctx.state, key, false)
117+
new_val = !current
118+
assign_key = String.to_atom(key)
119+
ctx = %{ctx | state: Map.put(ctx.state, key, new_val)}
120+
%{ctx | socket: assign(ctx.socket, assign_key, new_val)}
121+
end
122+
123+
# -- DOM --
124+
125+
defp execute_step(%{"action" => "show"} = step, ctx) do
126+
js_cmd = Phoenix.LiveView.JS.show(target_opts(step))
127+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
128+
end
129+
130+
defp execute_step(%{"action" => "hide"} = step, ctx) do
131+
js_cmd = Phoenix.LiveView.JS.hide(target_opts(step))
132+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
133+
end
134+
135+
defp execute_step(%{"action" => "toggle"} = step, ctx) do
136+
js_cmd = Phoenix.LiveView.JS.toggle(target_opts(step))
137+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
138+
end
139+
140+
defp execute_step(%{"action" => "add_class", "target" => target, "class" => class}, ctx) do
141+
js_cmd = Phoenix.LiveView.JS.add_class(class, to: target)
142+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
143+
end
144+
145+
defp execute_step(%{"action" => "remove_class", "target" => target, "class" => class}, ctx) do
146+
js_cmd = Phoenix.LiveView.JS.remove_class(class, to: target)
147+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
148+
end
149+
150+
defp execute_step(%{"action" => "toggle_class", "target" => target, "class" => class}, ctx) do
151+
js_cmd = Phoenix.LiveView.JS.toggle_class(class, to: target)
152+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
153+
end
154+
155+
defp execute_step(%{"action" => "set_attribute", "target" => target, "attr" => attr, "value" => value}, ctx) do
156+
js_cmd = Phoenix.LiveView.JS.set_attribute({attr, resolve_value(value, ctx)}, to: target)
157+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
158+
end
159+
160+
defp execute_step(%{"action" => "remove_attribute", "target" => target, "attr" => attr}, ctx) do
161+
js_cmd = Phoenix.LiveView.JS.remove_attribute(attr, to: target)
162+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
163+
end
164+
165+
defp execute_step(%{"action" => "transition", "target" => target, "class" => class} = step, ctx) do
166+
time = step["time"] || 200
167+
js_cmd = Phoenix.LiveView.JS.transition(class, to: target, time: time)
168+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
169+
end
170+
171+
defp execute_step(%{"action" => "focus", "target" => target}, ctx) do
172+
js_cmd = Phoenix.LiveView.JS.focus(to: target)
173+
%{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})}
174+
end
175+
176+
# -- Feedback --
177+
178+
defp execute_step(%{"action" => "flash", "kind" => kind, "message" => message}, ctx) do
179+
%{ctx | socket: put_flash(ctx.socket, String.to_atom(kind), resolve_value(message, ctx))}
180+
end
181+
182+
defp execute_step(%{"action" => "dispatch_event", "event" => event} = step, ctx) do
183+
detail = step["detail"] || %{}
184+
%{ctx | socket: push_event(ctx.socket, "beacon:dispatch", %{event: event, detail: detail})}
185+
end
186+
187+
defp execute_step(%{"action" => "push_event", "event" => event} = step, ctx) do
188+
payload = resolve_variables(step["payload"] || %{}, ctx)
189+
%{ctx | socket: push_event(ctx.socket, event, payload)}
190+
end
191+
192+
defp execute_step(%{"action" => "track", "event" => event} = step, ctx) do
193+
properties = step["properties"] || %{}
194+
%{ctx | socket: push_event(ctx.socket, "beacon:track", %{event: event, properties: properties})}
195+
end
196+
197+
# -- Forms --
198+
199+
defp execute_step(%{"action" => "validate", "form" => _form} = step, ctx) do
200+
# Validation is primarily client-side. Push the rules to the client.
201+
rules = step["rules"] || %{}
202+
%{ctx | socket: push_event(ctx.socket, "beacon:validate", %{form: step["form"], rules: rules})}
203+
end
204+
205+
# -- Control flow --
206+
207+
defp execute_step(%{"action" => "conditional", "test" => test} = step, ctx) do
208+
if evaluate_test(test, ctx) do
209+
execute_branch(step["then"], ctx)
210+
else
211+
execute_branch(step["else"], ctx)
212+
end
213+
end
214+
215+
defp execute_step(%{"action" => "sequence", "steps" => steps}, ctx) do
216+
execute_steps(steps, ctx)
217+
end
218+
219+
# -- Escape hatch --
220+
221+
defp execute_step(%{"action" => "custom", "handler" => handler_name} = step, ctx) do
222+
params = Map.merge(ctx.event_params, step["params"] || %{})
223+
224+
# Only dispatch to :elixir format handlers to prevent infinite recursion
225+
table = :beacon_runtime_poc
226+
key = {ctx.site, :site_handler, :event, handler_name}
227+
228+
case :ets.lookup(table, key) do
229+
[{_, {:elixir, _} = tagged}] ->
230+
case Beacon.RuntimeRenderer.dispatch_tagged_handler(tagged, params, ctx.socket) do
231+
{:noreply, socket} -> %{ctx | socket: socket}
232+
_ -> ctx
233+
end
234+
235+
[{_, {:actions, _}}] ->
236+
Logger.warning("[Beacon.Actions] Custom handler '#{handler_name}' is itself an actions handler — skipping to prevent recursion")
237+
ctx
238+
239+
_ ->
240+
Logger.warning("[Beacon.Actions] Custom handler '#{handler_name}' not found")
241+
ctx
242+
end
243+
end
244+
245+
# -- Unknown action (no-op with warning) --
246+
247+
defp execute_step(%{"action" => action}, ctx) do
248+
Logger.warning("[Beacon.Actions] Unknown action type: #{action}")
249+
ctx
250+
end
251+
252+
# -- Helpers --
253+
254+
defp execute_branch(nil, ctx), do: ctx
255+
defp execute_branch(steps, ctx) when is_list(steps), do: execute_steps(steps, ctx)
256+
257+
defp target_opts(%{"target" => target}), do: [to: target]
258+
defp target_opts(_), do: []
259+
260+
defp resolve_value(value, _ctx) when is_number(value) or is_boolean(value), do: value
261+
defp resolve_value(nil, _ctx), do: nil
262+
263+
defp resolve_value(value, ctx) when is_binary(value) do
264+
cond do
265+
String.starts_with?(value, "$event_params.") ->
266+
path = String.trim_leading(value, "$event_params.") |> String.split(".")
267+
get_nested(ctx.event_params, path)
268+
269+
String.starts_with?(value, "$state.") ->
270+
path = String.trim_leading(value, "$state.") |> String.split(".")
271+
get_nested(ctx.state, path)
272+
273+
String.starts_with?(value, "$result.") ->
274+
path = String.trim_leading(value, "$result.") |> String.split(".")
275+
get_nested(ctx.fetch_results, path)
276+
277+
String.starts_with?(value, "$error.") ->
278+
path = String.trim_leading(value, "$error.") |> String.split(".")
279+
get_nested(ctx.state, ["error" | path])
280+
281+
true ->
282+
value
283+
end
284+
end
285+
286+
defp resolve_value(value, _ctx) when is_map(value), do: value
287+
defp resolve_value(value, _ctx) when is_list(value), do: value
288+
289+
defp resolve_variables(variables, ctx) when is_map(variables) do
290+
Map.new(variables, fn
291+
{key, %{"source" => "event_param", "key" => param_key}} ->
292+
{key, get_nested(ctx.event_params, String.split(param_key, "."))}
293+
294+
{key, %{"source" => "state", "key" => state_key}} ->
295+
{key, Map.get(ctx.state, state_key)}
296+
297+
{key, %{"source" => "literal", "value" => value}} ->
298+
{key, value}
299+
300+
{key, %{"source" => "result", "from" => from, "path" => path}} ->
301+
{key, get_nested(ctx.fetch_results, [from | String.split(path, ".")])}
302+
303+
{key, value} ->
304+
{key, resolve_value(value, ctx)}
305+
end)
306+
end
307+
308+
defp evaluate_test(%{"path" => path, "op" => op, "value" => expected}, ctx) do
309+
actual = resolve_value("$" <> path, ctx)
310+
311+
case op do
312+
"eq" -> actual == expected
313+
"neq" -> actual != expected
314+
"gt" -> is_number(actual) and actual > expected
315+
"lt" -> is_number(actual) and actual < expected
316+
"gte" -> is_number(actual) and actual >= expected
317+
"lte" -> is_number(actual) and actual <= expected
318+
"contains" -> is_binary(actual) and String.contains?(actual, expected)
319+
"exists" -> actual != nil
320+
"not_exists" -> actual == nil
321+
_ -> false
322+
end
323+
end
324+
325+
defp evaluate_test(%{"field" => field, "op" => op, "value" => expected}, ctx) do
326+
evaluate_test(%{"path" => field, "op" => op, "value" => expected}, ctx)
327+
end
328+
329+
defp evaluate_test(_, _ctx), do: false
330+
331+
defp get_nested(nil, _), do: nil
332+
defp get_nested(value, []), do: value
333+
defp get_nested(value, [key | rest]) when is_map(value) do
334+
get_nested(Map.get(value, key) || Map.get(value, String.to_atom(key)), rest)
335+
end
336+
defp get_nested(_, _), do: nil
337+
end

lib/beacon/actions/security.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
defmodule Beacon.Actions.Security do
2+
@moduledoc false
3+
4+
@doc """
5+
Sign an action document with HMAC-SHA256.
6+
7+
The signature covers the JSON-encoded action document, preventing tampering.
8+
"""
9+
@spec sign(map(), binary()) :: binary()
10+
def sign(action_document, secret_key) when is_map(action_document) and is_binary(secret_key) do
11+
payload = Jason.encode!(action_document)
12+
:crypto.mac(:hmac, :sha256, secret_key, payload) |> Base.encode64()
13+
end
14+
15+
@doc """
16+
Verify an HMAC-SHA256 signature against an action document.
17+
"""
18+
@spec verify(map(), binary(), binary()) :: boolean()
19+
def verify(action_document, signature, secret_key)
20+
when is_map(action_document) and is_binary(signature) and is_binary(secret_key) do
21+
expected = sign(action_document, secret_key)
22+
23+
# Constant-time comparison to prevent timing attacks
24+
byte_size(expected) == byte_size(signature) and :crypto.hash_equals(expected, signature)
25+
end
26+
end

0 commit comments

Comments
 (0)