-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).