Current section
Files
Jump to
Current section
Files
src/acumen.erl
-module(acumen).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/acumen.gleam").
-export([directory/1, external_account_required/1, profiles/1, retry_after/1, terms_of_service/1, acme_error_from_type/5, build_post_request/2, build_fetch/3, identifier_decoder/0, subproblem_decoder/0, parse_acme_error/1, execute/3]).
-export_type([acme_error/0, context/0, directory/0, directory_meta/0, execute_error/1, identifier_/0, registered_key/0, retry_after/0, subproblem/0, unregistered_key/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Acumen is a Gleam library for interacting with ACME servers (such as Let's\n"
" Encrypt) to automate certificate issuance and management.\n"
"\n"
" ## Architecture\n"
"\n"
" Acumen uses a **sans-IO** pattern, meaning it produces HTTP request descriptions\n"
" and consumes response data rather than performing I/O directly. This makes it:\n"
"\n"
" - **HTTP client agnostic**: Use any HTTP library (gleam_httpc, gleam_fetch, etc.)\n"
" - **Target agnostic**: Works on both Erlang VM and JavaScript runtimes\n"
"\n"
" ## Quick Start\n"
"\n"
" ```gleam\n"
" import acumen\n"
" import acumen/nonce\n"
" import acumen/register_account\n"
" import gleam/http/request\n"
" import gleam/httpc\n"
" import gose/key\n"
" import kryptos/ec\n"
"\n"
" pub fn main() {\n"
" // 1. Fetch the ACME directory\n"
" let assert Ok(req) = request.to(\"https://acme-v02.api.letsencrypt.org/directory\")\n"
" let assert Ok(resp) = httpc.send(req)\n"
" let assert Ok(directory) = acumen.directory(resp)\n"
"\n"
" // 2. Get an initial nonce\n"
" let assert Ok(nonce_req) = nonce.build(directory)\n"
" let assert Ok(nonce_resp) = httpc.send(nonce_req)\n"
" let assert Ok(initial_nonce) = nonce.response(nonce_resp)\n"
"\n"
" // 3. Create context and account key\n"
" let ctx = acumen.Context(directory:, nonce: initial_nonce)\n"
" let key = key.generate_ec(ec.P256)\n"
" let unregistered = acumen.UnregisteredKey(key)\n"
"\n"
" // 4. Register an account\n"
" let reg = register_account.request()\n"
" |> register_account.contacts([\"mailto:admin@example.com\"])\n"
" |> register_account.agree_to_terms\n"
"\n"
" let assert Ok(#(resp, ctx)) = acumen.execute(\n"
" ctx,\n"
" build: register_account.build(reg, _, unregistered),\n"
" send: httpc.send,\n"
" )\n"
"\n"
" let assert Ok(#(account, registered_key)) =\n"
" register_account.response(resp, unregistered)\n"
" }\n"
" ```\n"
).
-type acme_error() :: {invalid_request, binary()} |
{invalid_response, binary()} |
{json_parse_error, binary()} |
{jws_error, binary()} |
{crypto_error, binary()} |
{invalid_challenge, binary()} |
{account_does_not_exist, binary()} |
{already_replaced, binary()} |
{already_revoked, binary()} |
{bad_csr, binary()} |
{bad_nonce, binary()} |
{bad_public_key, binary()} |
{bad_revocation_reason, binary()} |
{bad_signature_algorithm, binary()} |
{caa_error, binary()} |
{compound_error, binary(), list(subproblem())} |
{connection_error, binary()} |
{dns_error, binary()} |
{external_account_required, binary()} |
{incorrect_response, binary()} |
{invalid_contact, binary()} |
{malformed_error, binary()} |
{order_not_ready, binary()} |
{rate_limited, binary()} |
{rejected_identifier, binary()} |
{server_internal_error, binary()} |
{tls_error, binary()} |
{unauthorized, binary()} |
{unsupported_contact, binary()} |
{unsupported_identifier, binary()} |
{user_action_required, binary(), gleam@option:option(binary())} |
{unknown_error, binary(), binary(), gleam@option:option(integer())}.
-type context() :: {context, directory(), binary()}.
-type directory() :: {directory,
acumen@url:url(),
acumen@url:url(),
acumen@url:url(),
acumen@url:url(),
acumen@url:url(),
gleam@option:option(acumen@url:url()),
gleam@option:option(gleam@uri:uri()),
gleam@option:option(directory_meta())}.
-type directory_meta() :: {directory_meta,
gleam@option:option(gleam@uri:uri()),
gleam@option:option(gleam@uri:uri()),
list(binary()),
boolean(),
gleam@dict:dict(binary(), binary())}.
-type execute_error(AEDK) :: {protocol_error, acme_error(), context()} |
{transport_error, AEDK} |
nonce_retry_exhausted.
-type identifier_() :: {dns_identifier, binary()} | {ip_identifier, binary()}.
-type registered_key() :: {registered_key, gose:key(binary()), acumen@url:url()}.
-type retry_after() :: {retry_after_seconds, integer()} |
{retry_after_timestamp, gleam@time@timestamp:timestamp()}.
-type subproblem() :: {subproblem,
binary(),
binary(),
gleam@option:option(identifier_())}.
-type unregistered_key() :: {unregistered_key, gose:key(binary())}.
-file("src/acumen.gleam", 348).
-spec meta_decoder() -> gleam@dynamic@decode:decoder(directory_meta()).
meta_decoder() ->
gleam@dynamic@decode:optional_field(
<<"termsOfService"/utf8>>,
none,
gleam@dynamic@decode:optional(acumen@internal@utils:uri_decoder()),
fun(Terms_of_service) ->
gleam@dynamic@decode:optional_field(
<<"website"/utf8>>,
none,
gleam@dynamic@decode:optional(
acumen@internal@utils:uri_decoder()
),
fun(Website) ->
gleam@dynamic@decode:optional_field(
<<"caaIdentities"/utf8>>,
[],
gleam@dynamic@decode:list(
{decoder, fun gleam@dynamic@decode:decode_string/1}
),
fun(Caa_identities) ->
gleam@dynamic@decode:optional_field(
<<"externalAccountRequired"/utf8>>,
false,
{decoder,
fun gleam@dynamic@decode:decode_bool/1},
fun(External_account_required) ->
gleam@dynamic@decode:optional_field(
<<"profiles"/utf8>>,
maps:new(),
gleam@dynamic@decode:dict(
{decoder,
fun gleam@dynamic@decode:decode_string/1},
{decoder,
fun gleam@dynamic@decode:decode_string/1}
),
fun(Profiles) ->
gleam@dynamic@decode:success(
{directory_meta,
Terms_of_service,
Website,
Caa_identities,
External_account_required,
Profiles}
)
end
)
end
)
end
)
end
)
end
).
-file("src/acumen.gleam", 308).
-spec parse_directory_body(binary()) -> {ok, directory()} |
{error, acme_error()}.
parse_directory_body(Body) ->
Decoder = begin
gleam@dynamic@decode:field(
<<"newNonce"/utf8>>,
acumen@url:decoder(),
fun(New_nonce) ->
gleam@dynamic@decode:field(
<<"newAccount"/utf8>>,
acumen@url:decoder(),
fun(New_account) ->
gleam@dynamic@decode:field(
<<"newOrder"/utf8>>,
acumen@url:decoder(),
fun(New_order) ->
gleam@dynamic@decode:field(
<<"revokeCert"/utf8>>,
acumen@url:decoder(),
fun(Revoke_cert) ->
gleam@dynamic@decode:field(
<<"keyChange"/utf8>>,
acumen@url:decoder(),
fun(Key_change) ->
gleam@dynamic@decode:optional_field(
<<"newAuthz"/utf8>>,
none,
gleam@dynamic@decode:optional(
acumen@url:decoder()
),
fun(New_authz) ->
gleam@dynamic@decode:optional_field(
<<"renewalInfo"/utf8>>,
none,
gleam@dynamic@decode:optional(
acumen@internal@utils:uri_decoder(
)
),
fun(Renewal_info) ->
gleam@dynamic@decode:optional_field(
<<"meta"/utf8>>,
none,
gleam@dynamic@decode:optional(
meta_decoder(
)
),
fun(Meta) ->
gleam@dynamic@decode:success(
{directory,
New_nonce,
New_account,
New_order,
Revoke_cert,
Key_change,
New_authz,
Renewal_info,
Meta}
)
end
)
end
)
end
)
end
)
end
)
end
)
end
)
end
)
end,
_pipe = gleam@json:parse(Body, Decoder),
gleam@result:map_error(
_pipe,
fun(Error) ->
{json_parse_error,
acumen@internal@utils:json_parse_error_message(
<<"directory"/utf8>>,
Error
)}
end
).
-file("src/acumen.gleam", 298).
?DOC(
" Parses an ACME directory response.\n"
"\n"
" The directory is the entry point for all ACME operations. Fetch it with\n"
" a GET to the server's directory URL.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" // Let's Encrypt production\n"
" let assert Ok(req) = request.to(\"https://acme-v02.api.letsencrypt.org/directory\")\n"
" let assert Ok(resp) = httpc.send(req)\n"
" let assert Ok(directory) = acumen.directory(resp)\n"
"\n"
" // Let's Encrypt staging\n"
" let assert Ok(req) = request.to(\"https://acme-staging-v02.api.letsencrypt.org/directory\")\n"
" let assert Ok(resp) = httpc.send(req)\n"
" let assert Ok(directory) = acumen.directory(resp)\n"
" ```\n"
).
-spec directory(gleam@http@response:response(binary())) -> {ok, directory()} |
{error, acme_error()}.
directory(Resp) ->
case erlang:element(2, Resp) of
200 ->
parse_directory_body(erlang:element(4, Resp));
Status ->
{error,
{invalid_response,
<<"expected status 200, got "/utf8,
(erlang:integer_to_binary(Status))/binary>>}}
end.
-file("src/acumen.gleam", 510).
?DOC(
" Returns `True` if the server requires external account binding.\n"
"\n"
" Returns `False` if the directory has no metadata or the field is not set.\n"
).
-spec external_account_required(directory()) -> boolean().
external_account_required(Directory) ->
case erlang:element(9, Directory) of
{some, Meta} ->
erlang:element(5, Meta);
none ->
false
end.
-file("src/acumen.gleam", 529).
?DOC(
" Returns available certificate issuance profiles as a dictionary of\n"
" profile names to descriptions. Empty if the server advertises none.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let available_profiles = acumen.profiles(directory)\n"
" case dict.get(available_profiles, \"tlsserver\") {\n"
" Ok(description) -> io.println(\"TLS Server profile: \" <> description)\n"
" Error(Nil) -> io.println(\"TLS Server profile not available\")\n"
" }\n"
" ```\n"
).
-spec profiles(directory()) -> gleam@dict:dict(binary(), binary()).
profiles(Directory) ->
case erlang:element(9, Directory) of
{some, Meta} ->
erlang:element(6, Meta);
none ->
maps:new()
end.
-file("src/acumen.gleam", 555).
?DOC(
" Extracts the `Retry-After` header value from a response.\n"
"\n"
" Handles both delta-seconds and HTTP-date formats per RFC 9110.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" case acumen.retry_after(resp) {\n"
" Ok(acumen.RetryAfterSeconds(seconds)) -> {\n"
" // Wait for `seconds` before retrying\n"
" }\n"
" Ok(acumen.RetryAfterTimestamp(timestamp)) -> {\n"
" // Retry after `timestamp`\n"
" }\n"
" Error(Nil) -> {\n"
" // No retry-after header, use default backoff\n"
" }\n"
" }\n"
" ```\n"
).
-spec retry_after(gleam@http@response:response(any())) -> {ok, retry_after()} |
{error, nil}.
retry_after(Resp) ->
gleam@result:'try'(
gleam@http@response:get_header(Resp, <<"retry-after"/utf8>>),
fun(Value) -> case gleam_stdlib:parse_int(Value) of
{error, _} ->
_pipe = acumen@internal@utils:parse_http_date(Value),
gleam@result:map(
_pipe,
fun(Field@0) -> {retry_after_timestamp, Field@0} end
);
{ok, Seconds} when Seconds >= 0 ->
{ok, {retry_after_seconds, Seconds}};
{ok, _} ->
{error, nil}
end end
).
-file("src/acumen.gleam", 567).
?DOC(" Returns the terms of service URL from the directory metadata, if present.\n").
-spec terms_of_service(directory()) -> {ok, gleam@uri:uri()} | {error, nil}.
terms_of_service(Directory) ->
_pipe = erlang:element(9, Directory),
_pipe@1 = gleam@option:then(_pipe, fun(Meta) -> erlang:element(2, Meta) end),
gleam@option:to_result(_pipe@1, nil).
-file("src/acumen.gleam", 574).
?DOC(false).
-spec acme_error_from_type(
binary(),
binary(),
gleam@option:option(binary()),
list(subproblem()),
gleam@option:option(integer())
) -> acme_error().
acme_error_from_type(Type_, Detail, Instance, Subproblems, Status) ->
Error_type = begin
_pipe = gleam@string:split(Type_, <<":"/utf8>>),
_pipe@1 = gleam@list:last(_pipe),
gleam@result:unwrap(_pipe@1, Type_)
end,
case Error_type of
<<"accountDoesNotExist"/utf8>> ->
{account_does_not_exist, Detail};
<<"alreadyReplaced"/utf8>> ->
{already_replaced, Detail};
<<"alreadyRevoked"/utf8>> ->
{already_revoked, Detail};
<<"badCSR"/utf8>> ->
{bad_csr, Detail};
<<"badNonce"/utf8>> ->
{bad_nonce, Detail};
<<"badPublicKey"/utf8>> ->
{bad_public_key, Detail};
<<"badRevocationReason"/utf8>> ->
{bad_revocation_reason, Detail};
<<"badSignatureAlgorithm"/utf8>> ->
{bad_signature_algorithm, Detail};
<<"caa"/utf8>> ->
{caa_error, Detail};
<<"compound"/utf8>> ->
{compound_error, Detail, Subproblems};
<<"connection"/utf8>> ->
{connection_error, Detail};
<<"dns"/utf8>> ->
{dns_error, Detail};
<<"externalAccountRequired"/utf8>> ->
{external_account_required, Detail};
<<"incorrectResponse"/utf8>> ->
{incorrect_response, Detail};
<<"invalidContact"/utf8>> ->
{invalid_contact, Detail};
<<"malformed"/utf8>> ->
{malformed_error, Detail};
<<"orderNotReady"/utf8>> ->
{order_not_ready, Detail};
<<"rateLimited"/utf8>> ->
{rate_limited, Detail};
<<"rejectedIdentifier"/utf8>> ->
{rejected_identifier, Detail};
<<"serverInternal"/utf8>> ->
{server_internal_error, Detail};
<<"tls"/utf8>> ->
{tls_error, Detail};
<<"unauthorized"/utf8>> ->
{unauthorized, Detail};
<<"unsupportedContact"/utf8>> ->
{unsupported_contact, Detail};
<<"unsupportedIdentifier"/utf8>> ->
{unsupported_identifier, Detail};
<<"userActionRequired"/utf8>> ->
{user_action_required, Detail, Instance};
_ ->
{unknown_error, Type_, Detail, Status}
end.
-file("src/acumen.gleam", 637).
?DOC(false).
-spec build_post_request(acumen@url:url(), binary()) -> {ok,
gleam@http@request:request(binary())} |
{error, acme_error()}.
build_post_request(Url, Body) ->
_pipe = acumen@internal@utils:request_from_url(Url),
_pipe@1 = gleam@http@request:set_method(_pipe, post),
_pipe@2 = gleam@http@request:set_header(
_pipe@1,
<<"content-type"/utf8>>,
<<"application/jose+json"/utf8>>
),
_pipe@3 = gleam@http@request:set_body(_pipe@2, Body),
{ok, _pipe@3}.
-file("src/acumen.gleam", 617).
?DOC(false).
-spec build_fetch(acumen@url:url(), context(), registered_key()) -> {ok,
gleam@http@request:request(binary())} |
{error, acme_error()}.
build_fetch(Url, Context, Key) ->
gleam@result:'try'(
begin
_pipe = acumen@internal@jws:sign_with_kid(
erlang:element(2, Key),
erlang:element(3, Key),
<<""/utf8>>,
erlang:element(3, Context),
Url
),
gleam@result:map_error(
_pipe,
fun(Field@0) -> {jws_error, Field@0} end
)
end,
fun(Body) -> build_post_request(Url, Body) end
).
-file("src/acumen.gleam", 649).
?DOC(false).
-spec identifier_decoder() -> gleam@dynamic@decode:decoder(identifier_()).
identifier_decoder() ->
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Type_) ->
gleam@dynamic@decode:field(
<<"value"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Value) -> case Type_ of
<<"dns"/utf8>> ->
gleam@dynamic@decode:success(
{dns_identifier, Value}
);
<<"ip"/utf8>> ->
gleam@dynamic@decode:success({ip_identifier, Value});
_ ->
gleam@dynamic@decode:failure(
{dns_identifier, Value},
<<"IdentifierType"/utf8>>
)
end end
)
end
).
-file("src/acumen.gleam", 660).
?DOC(false).
-spec subproblem_decoder() -> gleam@dynamic@decode:decoder(subproblem()).
subproblem_decoder() ->
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Type_) ->
gleam@dynamic@decode:optional_field(
<<"detail"/utf8>>,
<<""/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Detail) ->
gleam@dynamic@decode:optional_field(
<<"identifier"/utf8>>,
none,
gleam@dynamic@decode:optional(identifier_decoder()),
fun(Identifier) ->
gleam@dynamic@decode:success(
{subproblem, Type_, Detail, Identifier}
)
end
)
end
)
end
).
-file("src/acumen.gleam", 476).
?DOC(false).
-spec parse_acme_error(gleam@http@response:response(binary())) -> acme_error().
parse_acme_error(Resp) ->
Decoder = begin
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Type_) ->
gleam@dynamic@decode:optional_field(
<<"detail"/utf8>>,
<<""/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Detail) ->
gleam@dynamic@decode:optional_field(
<<"instance"/utf8>>,
none,
gleam@dynamic@decode:optional(
{decoder,
fun gleam@dynamic@decode:decode_string/1}
),
fun(Instance) ->
gleam@dynamic@decode:optional_field(
<<"subproblems"/utf8>>,
[],
gleam@dynamic@decode:list(
subproblem_decoder()
),
fun(Subproblems) ->
gleam@dynamic@decode:success(
{Type_,
Detail,
Instance,
Subproblems}
)
end
)
end
)
end
)
end
)
end,
case gleam@json:parse(erlang:element(4, Resp), Decoder) of
{error, Error} ->
{json_parse_error,
acumen@internal@utils:json_parse_error_message(
<<"error response"/utf8>>,
Error
)};
{ok, {Type_@1, Detail@1, Instance@1, Subproblems@1}} ->
acme_error_from_type(
Type_@1,
Detail@1,
Instance@1,
Subproblems@1,
{some, erlang:element(2, Resp)}
)
end.
-file("src/acumen.gleam", 468).
-spec parse_error_response(gleam@http@response:response(binary()), context()) -> {ok,
{gleam@http@response:response(binary()), context()}} |
{error, execute_error(any())}.
parse_error_response(Resp, Context) ->
{error, {protocol_error, parse_acme_error(Resp), Context}}.
-file("src/acumen.gleam", 453).
-spec handle_bad_request(
gleam@http@response:response(binary()),
context(),
integer(),
fun((context()) -> {ok, gleam@http@request:request(binary())} |
{error, acme_error()}),
fun((gleam@http@request:request(binary())) -> {ok,
gleam@http@response:response(binary())} |
{error, AEEV})
) -> {ok, {gleam@http@response:response(binary()), context()}} |
{error, execute_error(AEEV)}.
handle_bad_request(Resp, Context, Nonce_retries, Build_request, Send) ->
case parse_error_response(Resp, Context) of
{error, {protocol_error, {bad_nonce, _}, _}} when Nonce_retries > 0 ->
do_execute(Context, Build_request, Send, Nonce_retries - 1);
{error, {protocol_error, {bad_nonce, _}, _}} ->
{error, nonce_retry_exhausted};
Other ->
Other
end.
-file("src/acumen.gleam", 425).
-spec do_execute(
context(),
fun((context()) -> {ok, gleam@http@request:request(binary())} |
{error, acme_error()}),
fun((gleam@http@request:request(binary())) -> {ok,
gleam@http@response:response(binary())} |
{error, AEEI}),
integer()
) -> {ok, {gleam@http@response:response(binary()), context()}} |
{error, execute_error(AEEI)}.
do_execute(Context, Build_request, Send, Nonce_retries) ->
gleam@result:'try'(
begin
_pipe = Build_request(Context),
gleam@result:map_error(
_pipe,
fun(_capture) -> {protocol_error, _capture, Context} end
)
end,
fun(Req) ->
gleam@result:'try'(
begin
_pipe@1 = Send(Req),
gleam@result:map_error(
_pipe@1,
fun(Field@0) -> {transport_error, Field@0} end
)
end,
fun(Resp) ->
Context@1 = case gleam@http@response:get_header(
Resp,
<<"replay-nonce"/utf8>>
) of
{error, nil} ->
Context;
{ok, Nonce} ->
{context, erlang:element(2, Context), Nonce}
end,
case erlang:element(2, Resp) of
Status when (Status >= 200) andalso (Status < 300) ->
{ok, {Resp, Context@1}};
400 ->
handle_bad_request(
Resp,
Context@1,
Nonce_retries,
Build_request,
Send
);
_ ->
parse_error_response(Resp, Context@1)
end
end
)
end
).
-file("src/acumen.gleam", 417).
?DOC(
" Executes an ACME request with automatic nonce retry handling.\n"
"\n"
" Builds the signed request, sends it, and retries on `badNonce` errors\n"
" (up to 3 retries). Updates the context with fresh nonces from each\n"
" response.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let registration = register_account.request()\n"
" |> register_account.contacts([\"mailto:admin@example.com\"])\n"
" |> register_account.agree_to_terms\n"
"\n"
" let result = acumen.execute(\n"
" ctx,\n"
" build: register_account.build(registration, _, unregistered_key),\n"
" send: httpc.send,\n"
" )\n"
"\n"
" case result {\n"
" Ok(#(resp, new_ctx)) -> {\n"
" // Process successful response\n"
" }\n"
" Error(acumen.ProtocolError(error: acumen.RateLimited(_), context: _)) -> {\n"
" // Back off and retry later\n"
" }\n"
" Error(acumen.TransportError(e)) -> {\n"
" // Handle network error\n"
" }\n"
" Error(acumen.NonceRetryExhausted) -> {\n"
" // All retries failed\n"
" }\n"
" }\n"
" ```\n"
).
-spec execute(
context(),
fun((context()) -> {ok, gleam@http@request:request(binary())} |
{error, acme_error()}),
fun((gleam@http@request:request(binary())) -> {ok,
gleam@http@response:response(binary())} |
{error, AEDW})
) -> {ok, {gleam@http@response:response(binary()), context()}} |
{error, execute_error(AEDW)}.
execute(Context, Build_request, Send) ->
do_execute(Context, Build_request, Send, 3).