{"id":48571725,"url":"https://github.com/jtdowney/acumen","last_synced_at":"2026-05-11T13:40:20.166Z","repository":{"id":347870532,"uuid":"1195529560","full_name":"jtdowney/acumen","owner":"jtdowney","description":"Gleam library for interacting with Automatic Certificate Management Environment (ACME) servers like Let's Encrypt","archived":false,"fork":false,"pushed_at":"2026-04-18T21:48:58.000Z","size":123,"stargazers_count":4,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-18T23:33:54.923Z","etag":null,"topics":["acme","cryptography","gleam","letsencrypt"],"latest_commit_sha":null,"homepage":"","language":"Gleam","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jtdowney.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-29T19:17:48.000Z","updated_at":"2026-04-18T21:47:48.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/jtdowney/acumen","commit_stats":null,"previous_names":["jtdowney/acumen"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/jtdowney/acumen","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jtdowney%2Facumen","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jtdowney%2Facumen/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jtdowney%2Facumen/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jtdowney%2Facumen/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jtdowney","download_url":"https://codeload.github.com/jtdowney/acumen/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jtdowney%2Facumen/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32214420,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-24T03:15:14.334Z","status":"ssl_error","status_checked_at":"2026-04-24T03:15:11.608Z","response_time":64,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["acme","cryptography","gleam","letsencrypt"],"created_at":"2026-04-08T15:00:21.856Z","updated_at":"2026-04-24T08:00:32.809Z","avatar_url":"https://github.com/jtdowney.png","language":"Gleam","funding_links":[],"categories":["Packages"],"sub_categories":["Cryptography"],"readme":"# acumen\n\n[![Package Version](https://img.shields.io/hexpm/v/acumen)](https://hex.pm/packages/acumen)\n[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/acumen/)\n\nAcumen is a Gleam library for interacting with [Automatic Certificate Management Environment (ACME)](https://tools.ietf.org/html/rfc8555) servers like [Let's Encrypt](https://letsencrypt.org). It handles account registration, domain challenges, and TLS certificate issuance.\n\n## Features\n\n- Uses a sans-IO pattern - produces HTTP request descriptions and consumes responses rather than performing I/O directly\n- HTTP client agnostic - use any HTTP library (`gleam_httpc`, `gleam_fetch`, etc.)\n- Works on both Erlang and JavaScript targets\n- Supports ACME Renewal Information (ARI) per [RFC 9773](https://tools.ietf.org/html/rfc9773) for server-suggested renewal windows\n- Tested against [Pebble](https://github.com/letsencrypt/pebble)\n\n## Installation\n\n```sh\ngleam add acumen\n```\n\nYou will also need an HTTP client library to send the requests that acumen builds. For Erlang targets, [`gleam_httpc`](https://hex.pm/packages/gleam_httpc) is a good choice. For JavaScript targets, [`gleam_fetch`](https://hex.pm/packages/gleam_fetch) works well.\n\n## Examples\n\nAcumen follows a build -\u003e send -\u003e parse pattern for every operation. The `acumen.execute` function orchestrates this loop and handles automatic `badNonce` retry.\n\n### Fetching the directory and initial nonce\n\nBefore any ACME operation, you need to fetch the server directory and an initial replay nonce.\n\n```gleam\nimport acumen\nimport acumen/nonce\nimport gleam/http/request\nimport gleam/httpc\n\n// Fetch the ACME directory\nlet assert Ok(req) = request.to(\"https://acme-staging-v02.api.letsencrypt.org/directory\")\nlet assert Ok(resp) = httpc.send(req)\nlet assert Ok(directory) = acumen.directory(resp)\n\n// Get an initial nonce\nlet assert Ok(nonce_req) = nonce.build(directory)\nlet assert Ok(nonce_resp) = httpc.send(nonce_req)\nlet assert Ok(initial_nonce) = nonce.response(nonce_resp)\n\n// Create context for subsequent requests\nlet ctx = acumen.Context(directory:, nonce: initial_nonce)\n```\n\n### Registering an account\n\nGenerate a key and register an account with the ACME server.\n\n```gleam\nimport acumen/register_account\nimport gose/jwk\nimport kryptos/ec\n\n// Generate an account key\nlet key = jwk.generate_ec(ec.P256)\nlet unregistered = acumen.UnregisteredKey(key)\n\n// Build and submit the registration\nlet reg = register_account.request()\n  |\u003e register_account.contacts([\"mailto:admin@example.com\"])\n  |\u003e register_account.agree_to_terms\n\nlet assert Ok(#(resp, ctx)) = acumen.execute(\n  ctx,\n  build: register_account.build(reg, _, unregistered),\n  send: httpc.send,\n)\n\nlet assert Ok(#(account, registered_key)) =\n  register_account.response(resp, unregistered)\n```\n\nFrom here on, use `registered_key` for all subsequent operations.\n\n### Creating an order\n\nCreate an order for the domains you want a certificate for.\n\n```gleam\nimport acumen/create_order\n\n// Identifiers can be DNS names or IP addresses\nlet assert Ok(order_req) = create_order.request(\n  identifiers: [acumen.DnsIdentifier(\"example.com\"), acumen.DnsIdentifier(\"www.example.com\")],\n)\n// For IP certificates: acumen.IpIdentifier(\"192.0.2.1\")\n\nlet assert Ok(#(resp, ctx)) = acumen.execute(\n  ctx,\n  build: create_order.build(order_req, _, registered_key),\n  send: httpc.send,\n)\n\nlet assert Ok(ord) = create_order.response(resp)\n```\n\n### Completing challenges\n\nFor each authorization in the order, fetch it and complete the appropriate challenge.\n\n```gleam\nimport acumen/challenge\nimport acumen/fetch_authorization\nimport acumen/validate_challenge\nimport gleam/list\n\nlet assert [auth_url, ..] = ord.authorizations\n\n// Fetch the authorization\nlet assert Ok(#(resp, ctx)) = acumen.execute(\n  ctx,\n  build: fetch_authorization.build(auth_url, _, registered_key),\n  send: httpc.send,\n)\n\nlet assert Ok(auth) = fetch_authorization.response(resp, auth_url)\n\n// Find the HTTP-01 challenge\nlet assert Ok(http_challenge) =\n  challenge.find_by_type(auth.challenges, of: challenge.Http01)\n\n// Compute the key authorization value\nlet assert Ok(key_auth) = challenge.key_authorization(http_challenge, registered_key)\nlet assert Ok(token) = challenge.token(http_challenge)\n// Deploy: GET /.well-known/acme-challenge/{token} -\u003e key_auth\n\n// Tell the server to validate\nlet assert Ok(#(resp, ctx)) = acumen.execute(\n  ctx,\n  build: validate_challenge.build(challenge.url(http_challenge), _, registered_key),\n  send: httpc.send,\n)\n\nlet assert Ok(validated_challenge) = validate_challenge.response(resp)\n```\n\n### Finalizing the order\n\nOnce all challenges are validated, generate a CSR and finalize the order.\n\n```gleam\nimport acumen/finalize_order\nimport acumen/order\nimport kryptos/ec\n\n// Generate a certificate key pair and CSR from the order\n// Use order.to_rsa_csr(ord, rsa_key) for RSA keys\nlet #(cert_key, _pub) = ec.generate_key_pair(ec.P256)\nlet assert Ok(csr) = order.to_ec_csr(ord, cert_key)\n\n// Finalize the order\nlet assert Ok(#(resp, ctx)) = acumen.execute(\n  ctx,\n  build: finalize_order.build(ord.finalize_url, _, registered_key, csr:),\n  send: httpc.send,\n)\n\nlet assert Ok(finalized_order) = finalize_order.response(resp, ord.url)\n```\n\n### Fetching the certificate\n\nAfter the order is finalized, poll until the order reaches the `Valid` status, then download the certificate.\n\n```gleam\nimport acumen/fetch_certificate\nimport acumen/fetch_order\nimport acumen/order\nimport gleam/erlang/process\n\n// Poll until the order is valid\nlet assert Ok(#(resp, ctx)) = acumen.execute(\n  ctx,\n  build: fetch_order.build(ord.url, _, registered_key),\n  send: httpc.send,\n)\n\nlet assert Ok(completed_order) = fetch_order.response(resp, ord.url)\n\n// The certificate URL is inside the Valid status variant\nlet assert order.Valid(cert_url) = completed_order.status\n\n// Download the certificate chain (PEM format)\nlet assert Ok(#(resp, ctx)) = acumen.execute(\n  ctx,\n  build: fetch_certificate.build(cert_url, _, registered_key),\n  send: httpc.send,\n)\n\nlet assert Ok(pem_chain) = fetch_certificate.response(resp)\n// pem_chain contains the full certificate chain in PEM format\n```\n\nIf the order is still processing, use `acumen.retry_after(resp)` to determine how long to wait before polling again.\n\n### Error handling\n\nAll operations return `Result` types. When using `acumen.execute`, errors are wrapped in `ExecuteError`:\n\n- `ProtocolError(error: acme_error, context: context)` — the ACME server returned an error with updated context (see `AcmeError` for all variants)\n- `TransportError(e)` — your HTTP client returned an error\n- `NonceRetryExhausted` — automatic `badNonce` retry failed after 4 attempts\n\n### Other challenge types\n\nAcumen supports multiple challenge types beyond HTTP-01:\n\n- DNS-01: Use `challenge.dns01_txt_record(key_auth)` to compute the TXT record value for `_acme-challenge.\u003cdomain\u003e`\n- DNS-Account-01: Use `challenge.dns_account01_txt_record(for: domain, account_url: kid, key_authorization: key_auth)` for account-bound DNS challenges — returns `Result(#(record_name, record_value), AcmeError)`\n- TLS-ALPN-01: Parsed and decoded; the caller must handle serving the `acme-tls/1` ALPN certificate with the acmeIdentifier extension\n- DNS-Persist-01: Use `challenge.dns_persist01_txt_record(issuer: issuer_name, account_url: kid)` for persistent DNS challenges\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjtdowney%2Facumen","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjtdowney%2Facumen","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjtdowney%2Facumen/lists"}