Docs / Decisions
ADR 0028: The CLI is an API client, modeled on `gh`; only server administration touches the database
- Status: accepted; built (device sign-in and user tokens, a test and a live credential per server,
/v1/membersand/v1/org,araldo admin);araldo listen;auth switchnot yet - Date: 2026-10-04
- Supersedes: ADR 0019 decision 1 ("the CLI takes operators with database access; there is no session-authenticated API") and decision 6's CLI half
Context
Today every CLI command opens the database and the master keys directly, and commands a member
would run (araldo members, araldo org, araldo channels) act as whoever --as <email> names.
Nothing proves the person at the keyboard is that member, two-factor never comes into it, and the
audit log records the named member rather than who ran the command. The real gate is access to
the deployment itself (a shell in a pod, or the database URL), so the CLI only works from inside it.
Two kinds of command are mixed together:
- Server administration, which has to work when the API cannot help: migrating the schema,
creating the first account before anyone can sign in, rotating master keys, resetting a locked-out
owner's password, minting the first API key. Every self-hosted product has this (a Rails console,
gitlab-rake), run by whoever operates the server. - Everything a member or an integration does: channels, members, org settings, and later posts
and templates. These have a front door already,
/v1, with real authentication, roles, scopes and an audit trail, andaraldo mcpalready uses it as a client.
API keys alone cannot carry the second kind: members and org settings deliberately take no key scope (ADR 0019 decision 6), and a long-lived org key on a laptop is the wrong credential for a person.
Decision
The model is the GitHub CLI (gh): its sign-in, credential handling and output are the standard
developers already know, so where this ADR does not say otherwise, do what gh does. For modes,
which gh has no counterpart of, the model is the Stripe CLI.
- Two kinds of command.
- Client commands call
/v1over HTTPS and never open the database:channels,members,org, and later posts and templates. They work from anywhere/v1is reachable. - Server administration lives under
araldo admin …and keeps direct access:bootstrap,keys generate|rotate|status,users create|reset-password,apikeys create. It runs where the deployment's configuration is (a pod, the host). It acts as the operator, not as a member: audit entries carry no member, request IDadmin-cli, and the command's name.--asgoes away. - The process commands stay top level:
server,worker,all,migrate(the chart's migration Job runsaraldo migrate),mcpandversion.
- Client commands call
araldo auth, asgh auth:- First, the simplest sign-in that works:
araldo auth login --hostname hopens the dashboard's/cli?device=<this computer>page in the browser (or prints its address, over SSH). The page asks for the password first (sudo mode), so nothing typed is lost to the confirmation; then a short form (the name, "araldo CLI on ", and a brand or all) makes a full-access key in the mode the CLI asked for (test, or live with--live), whatever mode the dashboard is showing, and shows only that key, to paste at the CLI's prompt, which does not echo. The CLI checks the pasted key's mode withGET /v1/meand refuses the wrong one. This isgh's "paste a token" path with the page opened for you: no new endpoints. The credential is an API key, so commands that only members may run (members,org) wait for the device flow below, built when the CLI needs them. - Later,
araldo auth loginwithout a pasted key: the OAuth 2.0 device authorization grant (RFC 8628). The CLI asksPOST /v1/auth/devicefor a device code and a short user code (ABCD-EFGH), prints! First copy your one-time code: ABCD-EFGH, offers to open the dashboard's/devicepage in the browser (never with the code in the link: typing it is what proves the request is the person's own, RFC 8628 §5.4, so the API offers noverification_uri_complete), and pollsPOST /v1/auth/device/tokenat the interval given until the code is approved, denied or expires (15 minutes).--with-tokenreads a token or an API key from stdin instead, for scripts and CI. - A test key and a live key per server, as the Stripe CLI keeps them. A key belongs to one
mode (ADR 0006), so the CLI keeps one of each:
auth loginadds the test key andauth login --livethe live one, each replacing only its own. Every client command (channels,api,mcp,auth token) uses the test key unless given--live, so a command that posts reaches real accounts only when asked to, asstripe --livedoes. With--with-token, a key read from stdin goes in its own mode's place. araldo auth status(each server, both keys, where each is stored, and whether it still works),auth logout(both keys),auth token [--live](prints it, for piping),auth switch(between accounts signed in on one server).- The member approves in the dashboard, signed in, which enforces the org's two-factor policy and, in an install behind an access proxy, the proxy too. Approving creates a credential, so it needs sudo mode (ADR 0007 decision 4). The page shows what is asking: the device name the CLI sent and its IP.
- It works the same on a laptop, over SSH and in a container, with no local web server.
- First, the simplest sign-in that works:
- User tokens.
ald_user_followed by 32 random characters (the distinctive prefix lets secret scanners find leaks, as with keys), shown once to the CLI, stored only as a SHA-256 hash.- A token is the person, like a
ghtoken, not bound to an org: each request names the org (Araldo-Org, an ID or name), as Stripe'sStripe-Accountheader names an account. The CLI sends it from--org,ARALDO_ORG, or the default saved witharaldo auth login --org …, asghremembers a default repository. - A token is bound to one mode, like a key: the device flow issues a test token, or a live
one with
--live, kept in the same two places as keys. One rule for every credential keeps test mode's promise (it never reaches a real account) without a header a script can forget. - Never more than the member: every request is checked against the member's current role in that org, so a role change or removal takes effect at once. Sudo-mode actions (creating API keys, changing two-factor, removing owners, deleting the org) stay dashboard-only.
- Valid until revoked, expiring only after a year unused, as GitHub does for
gh. Listed under Your account → Devices with device name, created and last used, and revocable there or witharaldo auth logout. Disabling or deleting the user revokes their tokens. - Audited as the member, with the token's ID, so CLI changes are attributable and distinguishable from the dashboard's.
/v1takes user tokens as well as keys. A route checks the caller's permissions the same way for both. New routes for what only members may do,/v1/membersand/v1/org, accept user tokens and refuse keys (ADR 0019 decision 6 stands for keys). Tokens are bearer credentials, not cookies, so CSRF does not apply.- Credentials and configuration on the client, as
ghkeeps them:- the token in the OS keychain (macOS Keychain, Windows Credential Manager, the Secret Service
on Linux) through
zalando/go-keyring, the libraryghuses: the standard library cannot reach a keychain, and a token on disk is the one secret a CLI most needs to protect. Without a keychain (a container, a headless server) it falls back to~/.config/araldo/hosts.yaml, mode 0600, andauth statussays so; - servers and defaults in
~/.config/araldo/($ARALDO_CONFIG_DIR, or the platform's config directory on Windows); ARALDO_TOKEN(a user token or an API key) andARALDO_HOSTin the environment override both, for CI.
- the token in the OS keychain (macOS Keychain, Windows Credential Manager, the Secret Service
on Linux) through
- Output, as
gh's: aligned tables in a terminal and tab-separated values when piped; color only in a terminal (NO_COLORrespected);--json [fields]with--jq(itchyny/gojq, asgh) for scripts; errors on stderr with a non-zero exit.araldo api <path>makes an authenticated request and prints the response, asgh apidoes. - Order of work:
- The client foundation: hosts and credentials (a test and a live key per server),
araldo api, table/JSON output, andchannels listas a client command;auth loginthrough the dashboard's key page. Done. - The device flow, user tokens, the
/devicepage and the Devices list. Done;auth loginuses it, and the dashboard's key page stays for CLIs that still open it. /v1/membersand/v1/org;membersandorgbecome client commands;--asis removed. Done (members join by invitation,/v1/invitations).- Server administration moves under
araldo admin, audited as the operator. Done; until step 3,membersandorgsit there too, acting as the operator in the org--orgnames, and the old top-level names still work, pointing to the new ones.
- The client foundation: hosts and credentials (a test and a live key per server),
Alternatives considered
- Keep the direct-database CLI. Simple, but it impersonates members, skips two-factor, audits the wrong person, and needs a shell in the deployment for routine work.
- API keys only. Members and org settings take no key scope on purpose, and an org-wide key is the wrong credential to keep on a laptop for a person.
- Browser sign-in with a localhost callback (PKCE). Smooth on a desktop, but fails over SSH and in containers, where operators often are. The device flow works everywhere.
- Personal access tokens pasted from the dashboard. The same token, with a worse handoff
(copying a secret by hand).
auth login --with-tokenkeeps that path open for unattended use. - Tokens bound to one org, like keys. A person belongs to several orgs; a token per org is the
friction
ghavoids by making the token the person and the repository a per-command choice. - One credential for both modes, with the mode chosen per request (an
Araldo-Livemodeheader). One sign-in instead of two, but every credential on a laptop could then post to real accounts, and a missing header would be the only thing keeping a test run in test mode. Keys are already mode-bound, and the Stripe CLI shows two sign-ins is little friction. - Reusing dashboard session cookies. Cookies carry CSRF rules and browser-only flags, and sessions are not meant to leave the browser.
Consequences
- Routine work no longer needs cluster access: a member runs
araldo auth loginagainst the public/v1from their own machine, and the audit log says who did what. - Shell access to the deployment becomes break-glass (
araldo admin), which operators can restrict. - The API grows a second credential type, the
Araldo-Orgheader,/v1/auth/device*,/v1/membersand/v1/org; the dashboard a device-approval page and a Devices list. The contract tests cover them. - Two new dependencies,
zalando/go-keyringanditchyny/gojq, both whatghuses. araldo members,araldo organd the--asflag change incompatibly. Before 1.0 that is acceptable; the release notes say so.