Elowen CLI pairing and enrollment
A computer running the Elowen CLI gets its own personal API token, minted by
UsersRepository.apiTokens.createCliDevice (packages/db/src/users/apiTokens.ts). The token row is the device: its
cli_target_id, metadata and last-seen columns live beside the token, /auth/api-tokens never lists
it, and revoking the token removes the device. Two ways lead to that token, both owned by core in
src/api/routes/cliDevices.ts (registerCliDeviceRoutes):
- Enrollment ticket. In Account > Computers the browser's system picks the installer (
browserOs, fromnavigator.userAgentData.platformornavigator.userAgent; only an unknown system, such as a phone, offers the Windows or macOS and Linux choice), and Copy install command issues a ticket and copies theshor PowerShell command that carries it. That one command installs the client, pairs the computer and starts the background service. - Device code.
elowen loginon a headless or manually installed client prints a link; the user approves the computer in the browser. The server package's own CLI asks for account sign-in instead when neitherELOWEN_URLnorELOWEN_DAEMON_URLis set; the installed client runs understandaloneand always pairs a computer.
Both answer only when the instance has a canonical public web URL: the one trustedPublicWebUrl(webBaseUrl())
derives from install metadata in src/daemon/brainCore.ts, passed to the routes as ServerDeps.publicWebUrl
and to plugins as ctx.publicWebUrl(). The request Host header never builds a link. Without that URL the
routes that hand out a link, a command or an installer answer 503 verification_unavailable.
Local account sign-in calls UsersRepository.apiTokens.create with no expiry and no device target. It creates
no computer Project or Computers entry; the token is visible and revocable in Account > API tokens, and it
keeps ordinary API-token permissions, limits and TOTP revocation, not browser-session authority. The CLI's
mode choice grants no server authority either: an anonymous code request still needs explicit browser
approval of its recorded kind, which the approval page displays and the decision body cannot change. No URL
hostname, port or request Host is trusted as a local-authentication exemption.
Who may call what
Neither the CLI nor the installer has an account credential, so their routes are public paths in isPublic
(src/api/auth.ts, exact method and path). The CLI's own routes authenticate by a secret in the JSON body;
the two installer downloads carry none. The web BFF forwards a request without cookie or Origin tokenless and
forwards only content-negotiation headers and the declared CLI client capabilities, which is why no ticket or
code ever travels in a URL or header.
Every decision for an account (approve, deny, issue a ticket, list devices) requires a human in a real
browser login (credentialScope 'full'). A personal API token, a turn's ElowenApi agent credential and an
impersonation session all get 403 browser_session_required, so no credential can enroll another.
Enrollment tickets
src/remoteCli/enrollment.ts (CliEnrollments) keeps the tickets:
- 32 random bytes, base64url (256 bits), bound to the account id that issued it.
- At most one live, unredeemed ticket per account. A second
POST /cli/enrollmentsreturns HTTP 409{ error: 'cli_enrollment_active', expiresAt, remainingMs }. GET /cli/enrollmentsreturns{ enrollment: { expiresAt, remainingMs } | null }for the signed-in account, withprivate, no-store. It never returns a ticket, command or cancellation id.- A successful mint returns
{ id, expiresAt, remainingMs, command }. The browser copies once and shows a disabled countdown, restored from the status read after F5. It samplesremainingMsagainstperformance.now(), rather than subtracting the browser wall clock from the server'sexpiresAt. Status is re-read on mount and every 10 seconds, releasing the lock after redemption or daemon restart. Changing the installer does not release it. The accessible button name gives the local availability time without announcing every second. - If the clipboard refuses the write,
DELETE /cli/enrollments/:idcancels only that account's exact mint before the copy-failed toast and another attempt. The UUID is a cancellation handle, not a download or redemption credential; an old handle cannot cancel a newer mint. Failed cancellation remains an error and the next status read preserves the live lock. - Valid for 10 minutes. Until it is consumed it authorizes the two download routes, so the installer can fetch the archive and the client can fetch the signed descriptor of its own version.
- Single use:
POST /cli/enrollchecks the live holder and account, reads canonical retry policy, then synchronously rechecks/consumes the ticket before minting, so concurrent redemptions mint at most one device. It answers strict{token,targetId,policy}(cliDeviceCredentialSchemainsrc/shared/cliDevices.ts). Unreadable policy returns typed 503settings_unreadablewithout consuming the ticket or minting a credential. The token belongs to the issuing account; if that account was deleted meanwhile the ticket answers like a spent one. - Unknown, used and expired tickets answer the same 410
expired. - Only in daemon memory, with an unref'd expiry timer. A daemon restart forgets every ticket; the user copies a new command.
- Never logged. The lifecycle logs (
CLI enrollment issued for account #<id>,CLI enrollment redeemed as API token <id> for account #<id>) carry account and token ids only.
Device codes
src/remoteCli/pairing.ts (CliPairings) keeps the pairing requests, ported from the removed
remote-execution plugin. A request has a 32-byte base64url deviceCode that only the CLI holds and an
8-character upper-case hex userCode the browser shows. It records the required kind ('account' or
'computer'), displayed in the approval card, and lives 10 minutes. Approval uses that stored kind, never a
new choice in the decision body: create for account sign-in, createCliDevice for a computer. The first
successful token poll hands out the token and deletes the record, so a later poll reads expired. Polling
reads the canonical policy before calling the synchronous one-shot CliPairings.poll(deviceCode, policy).
An approved response is {status:'approved',kind:'computer',token,targetId,policy} for pairing or
{status:'approved',kind:'account',token} for account sign-in; unavailable policy returns typed 503 without
losing the held credential, and simultaneous polls cannot receive it twice. This policy uses the same
isRetryPolicy validator and committed projection as /connection-policy, not enrollment-specific defaults.
A failed mint denies the request, answers 500 pairing_approval_failed and logs the problem code
cli.pairing_failed. Requests live in daemon memory and are lost on restart, and neither code nor token is
logged.
Installer
POST /cli/enrollments takes {installer: 'sh' | 'powershell'} (required, CLI_INSTALLERS in
src/remoteCli/installScript.ts) and answers the command for that installer from installCommand. Both
installers are rendered by src/remoteCli/installScript.ts, served as public paths and carry no secret: the
public web URL is baked in, checked to be canonical and quoted for its shell, and the ticket travels only in
the environment and then in a request body. In both the script body is one unit closed on its last line, so a
download cut short runs nothing. Verifying the release, redeeming the ticket and installing the service all
happen in the client's own client-install, never in the script.
sh (Linux and macOS)
GET /cli/install.sh serves renderInstallScript. The copied command is
{ curl -fsSL 'https://instance.example/api/cli/install.sh' || echo 'exit 1'; } | ELOWEN_ENROLLMENT_TICKET='<ticket>' sh
The ticket is an environment assignment of the sh that runs the script, not an argument: every local user can
read a process's arguments, only its owner can read its environment. A failed download feeds exit 1 to that
sh, so the command fails instead of running an empty script with status 0. The script body is one function
called on its last line.
The script is POSIX sh with set -eu, asks nothing and parses no JSON. In order it:
- fails without
ELOWEN_ENROLLMENT_TICKET; - maps
uname -stolinuxordarwinanduname -mtox64orarm64(anything else is named and refused). A Windows shell (MINGW*,MSYS*,CYGWIN*, for example Git Bash) is sent to the Windows command in Account > Computers; WSL reports Linux and installs the Linux client; - requires
curlandtar; - creates a
mktemp -ddirectory that an exit trap removes; - posts
{ticket, platform, arch}to/api/cli/enroll/archive, building the body withprintfand piping it to curl so the ticket stays out of the process list, and tells 404 (no release for this platform yet), 410 (expired or used command) and any other status apart; - unpacks the archive (top directory
package/) and runsELOWEN_URL='<url>' ELOWEN_ENROLLMENT_TICKET="$ticket" "$tmp/package/bin/elowen" client-install --archive "$tmp/client.tgz", exiting with its status after the cleanup trap.
PowerShell (Windows)
GET /cli/install.ps1 serves renderPowerShellInstallScript for Windows PowerShell 5.1 and PowerShell 7. The
copied command is
$env:ELOWEN_ENROLLMENT_TICKET='<ticket>'; try { irm -UseBasicParsing 'https://instance.example/api/cli/install.ps1' | iex } finally { $env:ELOWEN_ENROLLMENT_TICKET=$null }
Both values are PowerShell verbatim strings with ' doubled. irm fails on an HTTP error, so iex never runs
an error body, and the finally removes the ticket from the window when the download or the parse fails
before the script could. iex parses the whole text before it runs any of it, and the body is one & { ... } script
block whose closing brace is the last line, so a truncated download is a parse error. The script runs in the
user's own window: it never calls exit (that would close the window), every failure throws
Elowen CLI install failed: ..., and its preferences live in the block's own scope. It is ASCII only, because
Windows PowerShell 5.1 decodes a body without a byte order mark by the system code page. In order it:
- copies
$env:ELOWEN_ENROLLMENT_TICKETinto a local variable and removes it from the session environment, so nothing else started from the window inherits it; - refuses a Windows build below 19045 (
[Environment]::OSVersion.Version.Build): Elowen CLI needs Windows 10 22H2 or Windows 11. Nothing is created or downloaded before this check; - fails without the ticket, pointing to Account > Computers;
- maps the machine's
PROCESSOR_ARCHITECTUREfromHKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment(AMD64tox64,ARM64toarm64) and refuses anything else. The machine value names the native processor even in an emulated PowerShell;RuntimeInformation.OSArchitectureis$nullin Windows PowerShell 5.1; - creates a randomly named directory under
[IO.Path]::GetTempPath()that afinallyremoves (a removal failure is reported without replacing the outcome); - posts
{ticket, platform: 'win32', arch}withInvoke-WebRequest -UseBasicParsing -OutFileto/api/cli/enroll/archive; the HTTP status comes from the exception's response in both PowerShell versions, and 404 and 410 get the same messages as ininstall.sh; - unpacks the archive with
%SystemRoot%\System32\tar.exe -xzf; - sets
ELOWEN_URLandELOWEN_ENROLLMENT_TICKETonly around& "$tmp\package\runtime\node.exe" "$tmp\package\dist\cli\clientBin.js" client-install --archive $archive, removes both in afinally, and throws on a non-zero exit code; - adds the entries of the machine and user
Pathregistry values that the window's$env:Pathlacks, soelowenworks in the same window at once while the session keeps its own entries.
Downloads
Both enrollment downloads reuse clientReleaseResponse (src/licence/clientReleaseCache.ts) with the licence
handler deps, the same path as the authenticated /licence/client/:platform/:arch/:file route: the daemon
checks the instance licence, authenticates to the publisher and serves size and hash verified bytes from its
private cache. fetchClientRelease throws ClientReleaseHttpError with the publisher's status; a 404 becomes
404 notFound on every route, so a missing release is an answer and not a 500.
/cli/enroll/archive resolves latest through that same helper to the running daemon's core version.
It downloads that exact client archive; a missing release for that version returns 404 even when the
publisher has a newer client. First installation and the paired client's update feed share this authority.
Routes
Daemon paths; the browser and the CLI reach them as <origin>/api/<path>.
| Method, path | Auth | Body or query | Answer |
|---|---|---|---|
POST /cli/device/code |
public | {deviceName, kind:'account'|'computer'} |
{deviceCode, userCode, verificationUriComplete, expiresAt}, link <publicWebUrl>/account?cat=elowenCli&code=<userCode> |
POST /cli/device/token |
public | {deviceCode} |
{status:'pending'|'denied'|'expired'}, once {status:'approved',kind:'account',token} or {status:'approved',kind:'computer',token,targetId,policy} |
GET /cli/device?userCode= |
browser | {userCode, deviceName, kind, expiresAt}; 410 expired; 409 device_code_already_decided |
|
POST /cli/device/approve, /cli/device/deny |
browser | {userCode} |
{status:'approved'} or {status:'denied'}; same 410 and 409 |
POST /cli/enrollments |
browser | {installer: 'sh'|'powershell'} |
{id, expiresAt, remainingMs, command}; 409 cli_enrollment_active; 400 without a known installer |
GET /cli/enrollments |
browser | {enrollment: {expiresAt, remainingMs} | null} of the signed-in account |
|
DELETE /cli/enrollments/:id |
browser | {ok: true}; cancels only the caller's exact mint, a stale id cancels nothing |
|
GET /cli/install.sh |
public | the sh installer, text/plain; charset=utf-8, cache-control: no-store |
|
GET /cli/install.ps1 |
public | the PowerShell installer, same headers | |
POST /cli/enroll/archive |
ticket | {ticket, platform, arch} |
client archive matching the running daemon version; 410 expired; 404 notFound |
POST /cli/enroll/release |
ticket | {ticket, platform, arch, version} |
signed descriptor of that version; 410; 404 |
POST /cli/enroll |
ticket | {ticket, deviceName} |
consumes the ticket: {token, targetId, policy}; 410 |
GET /cli/devices/:targetId/stats |
browser | from, to (YYYY-MM-DD UTC days) |
{from, to, totals, days} of turns, errors, tokens, cost, commands and edited files; 400 for a bad window; 404 for a foreign or unknown computer |
deviceName follows the API token name rule (1 to 80 characters after trimming). expiresAt values are epoch
milliseconds. Computer facts, connection state and platform metadata are served through GET /projects
under ProjectView.computer (see paired computers). Revoking a device
is DELETE /auth/api-tokens/:id.
Tests: tests/api/cliDevices.test.ts drives the routes through the real app; tests/remoteCli/installScript.test.ts
runs the rendered sh script with sh against a local HTTP server and PATH shims, and pins the PowerShell
script by a snapshot and its safety properties (ASCII, no exit, one block, the ticket only through Env:, the
build check before any download), because no PowerShell runs on the build host; the real run is checked on a
Windows machine.