-module(albedo_settings_store).
-include_lib("kernel/include/file.hrl").
-export([with_lock/2, read/2, object/2, write/3, guarded/1, check/1,
transaction/6, capability/6, catalog_capability/4, mcp/5, validate_caps/1]).
%% Every persisted settings and credential mutation shares the home lock.
with_lock(Home, Run) ->
albedo_settings_lock:with_lock(Home, Run,
fun() -> {error, <<"settings store is busy">>} end).
read(Home, <<"capabilities.json">>) ->
case albedo_capabilities:read(Home) of
{ok, Bytes} ->
try json:decode(Bytes) of
Value when is_map(Value) -> Value;
_ -> throw({settings, <<"capabilities.json is not a readable JSON object">>})
catch _:_ -> throw({settings, <<"capabilities.json is not a readable JSON object">>}) end;
{error, Reason} -> throw({settings, Reason})
end;
read(Home, File) ->
Path = filename:join(Home, File),
case file:read_file_info(Path) of
{error, enoent} -> #{};
{ok, #file_info{type = regular, size = Size}} when Size =< 2097152 ->
case albedo_credentials:read(Path) of
{ok, Value} -> Value;
_ -> throw({settings, <<File/binary, " is not a readable JSON object">>})
end;
_ -> throw({settings, <<File/binary, " is not a readable settings file">>})
end.
object(Key, Map) ->
case maps:get(Key, Map, #{}) of
Value when is_map(Value) -> Value;
_ -> throw({settings, <<"invalid settings section">>})
end.
write(Home, File, Value) ->
Bytes = iolist_to_binary(json:encode(Value)),
case File =:= <<"capabilities.json">> andalso byte_size(Bytes) > albedo_capabilities:max_bytes() of
true -> throw({settings, <<"capabilities.json exceeds 1 MiB">>});
false -> ok
end,
case albedo_credentials:write(filename:join(Home, File), Bytes) of
ok -> ok;
_ -> throw({settings, <<"could not save ", File/binary>>})
end.
guarded(Run) ->
try Run() catch
throw:{settings, Error} -> {error, Error};
_:_ -> {error, <<"invalid settings">>}
end.
%% Rollback is deliberately scoped to the settings file and credential entry
%% affected by this mutation. Other credential entries remain untouched.
transaction(Home, File, Credential, Validate, Change, After) ->
with_lock(Home, fun() -> guarded(fun() ->
Prior = read(Home, File),
Validate(Prior),
PreviousSecret = case Credential of
none -> error;
{Section, Name} -> maps:find(Name, object(Section, read(Home, <<"creds.json">>)))
end,
try
Change(Prior),
case After() of
{ok, _} = Success -> Success;
{error, Error} -> throw({settings, Error})
end
catch Class:Reason ->
Error0 = case {Class, Reason} of {throw, {settings, Message}} -> Message; _ -> <<"invalid settings">> end,
Restorations = [guarded(fun() -> write(Home, File, Prior), {ok, nil} end),
guarded(fun() -> restore_secret(Home, Credential, PreviousSecret), {ok, nil} end)],
case [Message || {error, Message} <- Restorations] of
[] -> {error, Error0};
Errors ->
Detail = iolist_to_binary(lists:join(<<"; ">>, Errors)),
{error, <<Error0/binary, "; restoration failed: ", Detail/binary>>}
end
end
end) end).
restore_secret(_, none, _) -> ok;
restore_secret(Home, {Section, Name}, Previous) ->
Current = read(Home, <<"creds.json">>),
Entries = object(Section, Current),
Next = case Previous of {ok, Value} -> Entries#{Name => Value}; error -> maps:remove(Name, Entries) end,
write(Home, <<"creds.json">>, Current#{Section => Next}).
check({ok, _}) -> ok;
check({error, Error}) -> throw({settings, Error}).
capability(Home, Session, Kind, Name, Value, After) ->
transaction(Home, <<"capabilities.json">>, none, fun validate_caps/1, fun(Prior) ->
{Scope, Enabled} = Value,
Groups = case Scope of <<"global">> -> object(Scope, Prior); <<"session">> -> object(Session, object(<<"sessions">>, Prior)) end,
Items = object(Kind, Groups),
Next = case Enabled of none -> maps:remove(Name, Items); {some, V} -> Items#{Name => V} end,
Updated = Groups#{Kind => Next},
Document = case Scope of
<<"global">> -> Prior#{<<"global">> => Updated};
<<"session">> -> Prior#{<<"sessions">> => (object(<<"sessions">>, Prior))#{Session => Updated}}
end,
write(Home, <<"capabilities.json">>, Document)
end, After).
%% Revalidation and persistence share the same lock, including reload rollback.
catalog_capability(Home, Session, Resolve, After) ->
with_lock(Home, fun() ->
case Resolve() of
{error, _} = Error -> Error;
{ok, {Kind, Name, Scope, Enabled}} ->
capability(Home, Session, Kind, Name, {Scope, Enabled}, After)
end
end).
validate_caps(Config) ->
check('albedo@harness@capabilities':validate(Config)).
mcp(Home, Name, ServerJSON, SecretsJSON, After) ->
transaction(Home, <<"extensions.json">>, {<<"mcp">>, Name}, fun validate_mcp/1, fun(Prior) ->
MCP = object(<<"mcp">>, Prior), Servers = object(<<"servers">>, MCP),
Updated = case ServerJSON of
none -> maps:remove(Name, Servers);
{some, JSON} ->
Server = json:decode(JSON),
Servers#{Name => maps:merge(maps:get(Name, Servers, #{}), Server)}
end,
check(albedo_credentials:patch_mcp_settings(Home, Name, json:decode(SecretsJSON))),
write(Home, <<"extensions.json">>, Prior#{<<"mcp">> => MCP#{<<"servers">> => Updated}})
end, After).
validate_mcp(Value) ->
Servers = object(<<"servers">>, object(<<"mcp">>, Value)),
maps:foreach(fun(Name, Server) -> check(albedo_mcp:validate_settings(Name, {some, iolist_to_binary(json:encode(Server))}, <<"{}">>)) end, Servers).