From a83c86970690f2ecd8195fe41d182ae8ac061cf1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 15:57:10 -0400 Subject: [PATCH 01/18] docs(cli): slot token credentials into the canonical auth precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HARPER_CLI_REFRESH_TOKEN` / `HARPER_CLI_OPERATION_TOKEN` shipped in v5.2.0 (harper#1876) but never reached the reference docs. The Authentication Precedence list stopped at the saved `harper login` token, so the two variables a CI pipeline is supposed to use were absent from the one place that states which credential wins. Folded into main's existing numbered list rather than stated separately, so the order is given once: bearer tokens sit at position 5, above the saved login token and above the legacy `username=`/`password=` fallback, which is what cliOperations.ts does. Also documents the parts that are only discoverable by reading the source: - Token variables apply to remote targets only. A local operation goes over the domain socket and is already trusted, so attaching a bearer token there would opt out of that trust and 401 — hence the deliberate ignore. - A token namespace owns both halves. `HARPER_CLI_OPERATION_TOKEN` cannot pair with `CLI_TARGET_REFRESH_TOKEN`, which would otherwise run as one identity until expiry and then silently continue as another. - A set-but-blank namespace reports and falls back rather than silently running as whoever last logged in locally. - An env-sourced refreshed token stays in memory; there is no file entry to persist it to. - One refresh-token hash per user, so `--for-ci` as your own account revokes your own token. Hence the dedicated-CI-user warning. `harper login --for-ci` is documented in commands.md with the stdout/stderr split that makes `| gh secret set --env-file -` work without displaying the token. Verified against harper v5.2.2: bin/cliOperations.ts (precedence and refresh), bin/login.ts (--for-ci output), config/configUtils.ts (1d / 30d defaults). Co-Authored-By: Claude Opus 5 --- reference/cli/authentication.md | 60 +++++++++++++++++++++++++++++++-- reference/cli/commands.md | 17 ++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 50a56af17..d211f20dc 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -42,11 +42,14 @@ For remote Operations API commands, the CLI uses the first complete authenticati 2. Credentials embedded in the `target` URL 3. `HARPER_CLI_USERNAME` and `HARPER_CLI_PASSWORD` environment variables 4. Legacy `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` environment variables -5. A token saved by `harper login` -6. `username=` and `password=` operation parameters (legacy fallback) +5. `HARPER_CLI_OPERATION_TOKEN` and `HARPER_CLI_REFRESH_TOKEN` environment variables, or their legacy `CLI_TARGET_` equivalents — see [Token credentials for CI/CD](#token-credentials-for-cicd) +6. A token saved by `harper login` +7. `username=` and `password=` operation parameters (legacy fallback) Credentials are resolved as a pair and are never combined across sources. An incomplete pair supplied with dedicated authentication parameters or in the target URL causes the command to fail. An incomplete environment-variable pair is skipped with a warning so that a saved login token can still be used. +Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. + Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`. ### Authentication Methods @@ -94,9 +97,15 @@ Starting in v5.2.0, a complete environment-variable credential pair takes preced - `HARPER_CLI_TARGET` - Sets the default `target` for CLI commands. `CLI_TARGET` is the legacy equivalent. - `HARPER_CLI_USERNAME` and `HARPER_CLI_PASSWORD` - Preferred credential pair for the target. - `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` - Lower-priority legacy credential pair. +- `HARPER_CLI_REFRESH_TOKEN` - Long-lived token the CLI exchanges for a fresh operation token on each run. `CLI_TARGET_REFRESH_TOKEN` is the legacy equivalent. +- `HARPER_CLI_OPERATION_TOKEN` - A short-lived operation token supplied directly, for callers that mint their own. Each credential namespace is independent. For example, the CLI never combines `HARPER_CLI_USERNAME` with `CLI_TARGET_PASSWORD`. If either namespace supplies only a username or only a password, that incomplete pair is skipped with a warning. +The same rule holds for tokens, and for the same reason: whichever namespace supplies a token owns both halves of it. If `HARPER_CLI_OPERATION_TOKEN` were allowed to pair with `CLI_TARGET_REFRESH_TOKEN`, commands would run as the first identity until its operation token expired and then silently continue as the second. + +For a pipeline, prefer a token over `HARPER_CLI_PASSWORD`: it is scoped to authentication, it can be revoked without changing the account password, and it cannot be used to log in interactively. See [Token credentials for CI/CD](#token-credentials-for-cicd). + **Example `.env` file**: ```bash @@ -162,6 +171,53 @@ harper add_user \ target=https://prod-server.com:9925 ``` +##### Token credentials for CI/CD + + + +Rather than storing an admin password in your CI provider, log in once locally and hand CI a **refresh token**. The CLI mints a fresh, short-lived operation token from it on every run, so the only durable secret the pipeline holds is a revocable token. + +`harper login --for-ci` writes the variables CI needs to **stdout** in `.env` format — and nothing else, so the output pipes cleanly. Everything a human reads (banner, prompts, status, warnings) goes to stderr: + +```bash +# Set both GitHub Actions secrets in one command — the token is never displayed +harper login --for-ci | gh secret set --env-file - + +# Or copy them to the clipboard to paste in by hand +harper login --for-ci | pbcopy +``` + +The block it emits: + +```bash +HARPER_CLI_TARGET=https://example.com:9925/ +HARPER_CLI_REFRESH_TOKEN=eyJhbGciOi... +``` + +Because stdout carries only these two lines, the token never appears on screen or in your shell history — which is not true of copying it out of terminal output by hand. If the cluster returns no refresh token, the command fails rather than emitting a half-block that would "succeed" at storing nothing. + +Expose the two values to the deploy step and no other credentials are needed: + +```yaml +- name: Deploy + run: harper deploy project=my-app restart=true replicated=true + env: + HARPER_CLI_TARGET: ${{ secrets.HARPER_CLI_TARGET }} + HARPER_CLI_REFRESH_TOKEN: ${{ secrets.HARPER_CLI_REFRESH_TOKEN }} +``` + +**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. If the refresh token itself is rejected, the command reports that and exits non-zero rather than falling back to another identity. + +**A blank token variable is an error, not a fallback.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI says so and falls back to saved login credentials. It does not silently run as whoever last logged in on that machine. + +**Lifetimes.** Operation tokens expire after `authentication.operationTokenTimeout` (default `1d`) and refresh tokens after `authentication.refreshTokenTimeout` (default `30d`). The pipeline needs a new refresh token when that window closes. + +:::warning +**Each user holds only one valid refresh token at a time.** Harper stores a single refresh-token hash per user, so minting a new one revokes that user's previous token. A routine local `harper login` as the same account will break a pipeline holding the older token, and the failure only surfaces on the pipeline's next refresh. + +Create a **dedicated CI user** and run `harper login --for-ci` as that user. That scopes the pipeline's permissions to what it actually needs, and lets you revoke its access without disturbing anyone else. +::: + #### Method 3: Dedicated Authentication Parameters diff --git a/reference/cli/commands.md b/reference/cli/commands.md index 549ef2cb5..23e771c78 100644 --- a/reference/cli/commands.md +++ b/reference/cli/commands.md @@ -155,6 +155,7 @@ harper login **Optional Parameters**: - `` - The URL of the Harper instance to log in to. +- `--for-ci` - After logging in, print CI/CD credentials to stdout. Available since v5.2.0. **Prompts**: @@ -164,6 +165,22 @@ You'll be asked to type in the following information: - `` - Harper admin username. - `` - Harper admin password. +#### `--for-ci` + + + +Prints `HARPER_CLI_TARGET` and `HARPER_CLI_REFRESH_TOKEN` to **stdout** in `.env` format — and nothing else, so the output pipes directly into a secret store without the token being displayed. Everything else (banner, prompts, status, warnings) goes to stderr: + +```bash +# Set both GitHub Actions secrets in one command +harper login --for-ci | gh secret set --env-file - + +# Or copy them to paste in by hand +harper login --for-ci | pbcopy +``` + +Run this as a **dedicated CI user**, not your own account: a user holds only one valid refresh token at a time, so issuing one for CI revokes any other token that user already had. See [Token credentials for CI/CD](authentication.md#token-credentials-for-cicd) for how the CLI consumes these variables and where they sit in authentication precedence. + ### `harper logout` Available since: v5.0.0 From 50292cb415f3dc2121c2d90b0ba97a328d693aab Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 17 Aug 2026 16:22:21 -0400 Subject: [PATCH 02/18] docs(release-notes): cover CI token credentials in 5.2 The other half of @kriszyp's discoverability finding, for the piece that actually shipped in v5.2.0. A user who only reads release notes had no way to discover that a pipeline can authenticate with a token instead of an admin password. States the two properties that change how someone sets this up: the token variables outrank a saved login (so a runner with both uses the CI identity), and one refresh-token hash per user means issuing a token for CI revokes your own if you run it as yourself. The 5.3 surface goes in 5.3.md, added on #599. --- release-notes/v5-lincoln/5.2.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 5e90160cb..3cf2ee328 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -58,6 +58,14 @@ New `write-transaction-queue-depth` and `read-transaction-queue-depth` metrics r CLI Operations API commands now accept dedicated `auth_username=` and `auth_password=` parameters, allowing commands such as `add_user` and `alter_user` to authenticate as an administrator while keeping the affected user's `username=` and `password=` in the operation payload. Environment-variable credentials and saved `harper login` tokens now take precedence over the legacy `username=` and `password=` authentication fallback. Credential pairs are also resolved within one environment-variable namespace, preventing a username from `HARPER_CLI_*` from being combined with a password from legacy `CLI_TARGET_*` variables. See [CLI Authentication](/reference/v5/cli/authentication#authentication-precedence). +### Token Credentials for CI/CD + +A pipeline no longer needs an admin password. `HARPER_CLI_REFRESH_TOKEN` supplies a long-lived token that the CLI trades for a fresh, short-lived operation token on every run, and `HARPER_CLI_OPERATION_TOKEN` supplies one directly for callers that mint their own. Both rank above a saved `harper login` token and above the legacy `username=`/`password=` fallback, so a configured CI identity is authoritative on a runner that also has a developer's login. A token refreshed from an environment variable is held in memory for that invocation only. + +`harper login --for-ci` provisions them: it prints `HARPER_CLI_TARGET` and `HARPER_CLI_REFRESH_TOKEN` to stdout in `.env` format and nothing else, so `harper login --for-ci | gh secret set --env-file -` stores both without the token being displayed. + +Because Harper keeps one refresh-token hash per user, issuing a token revokes that user's previous one — run `--for-ci` as a dedicated CI user rather than your own account. See [Token credentials for CI/CD](/reference/v5/cli/authentication#token-credentials-for-cicd). + ## HTTP ### Middleware routing and ordering From 613c2e8607bd31e574fa245ab68d968f3dd10222 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 21 Aug 2026 17:01:39 -0400 Subject: [PATCH 03/18] docs(cli): a blank token namespace is skipped, not a hard failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph contradicted itself: "an error, not a fallback", then described the fallback, then denied the behavior it had just described. The CLI warns and continues down the precedence list, which does mean running as whoever last logged in on that machine — the warning is the only thing that makes it non-silent. Caught by a review pass against #599, where this text also lives. Fixing it only there would have left this PR publishing the wrong version, since this is the PR that owns the paragraph and the one meant to merge first. --- reference/cli/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index d211f20dc..8e10e6e75 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -208,7 +208,7 @@ Expose the two values to the deploy step and no other credentials are needed: **Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. If the refresh token itself is rejected, the command reports that and exits non-zero rather than falling back to another identity. -**A blank token variable is an error, not a fallback.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI says so and falls back to saved login credentials. It does not silently run as whoever last logged in on that machine. +**A blank token variable is reported, then skipped.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI warns and continues down the precedence list, so the run proceeds under the saved `harper login` token if that machine has one. The warning is the only thing separating this from silently deploying as whoever last logged in, so treat it as a failure signal in CI rather than assuming a blank secret stops the run. **Lifetimes.** Operation tokens expire after `authentication.operationTokenTimeout` (default `1d`) and refresh tokens after `authentication.refreshTokenTimeout` (default `30d`). The pipeline needs a new refresh token when that window closes. From 5bd6ec0b552edecfe46bbfe015679039cd8ce10c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 13:08:36 -0400 Subject: [PATCH 04/18] docs(cli): narrow two token-handling claims to what 5.2.4 actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review at 613c2e86 — codex and gemini, the first run where the gemini leg produced output. **A rejected refresh token does not reliably stop the command.** The page said it "reports that and exits non-zero rather than falling back to another identity". Only a 401 does that. `refreshExpiredOperationToken` logs any other failure — a 5xx, a timeout, a connection error — and returns, leaving no bearer token set, so execution reaches the legacy `username=`/`password=` fallback and a command carrying that pair authenticates as it. Someone treating a refresh failure as a pipeline stop would be wrong in exactly the cases that matter: a flaky network or a degraded server. **A blank preferred namespace shadows a complete legacy one.** The namespace is picked with `.find()` on `!== undefined`, so `HARPER_CLI_REFRESH_TOKEN=` claims the choice while supplying nothing, and a valid `CLI_TARGET_REFRESH_TOKEN` is never read — the run drops to the saved login token instead. The owns-both-halves rule was documented; that it is decided by presence rather than by usable value was not. Advice added: unset the variable rather than blanking it. Both verified in `bin/cliOperations.ts` at v5.2.4 before changing the text. --- reference/cli/authentication.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 8e10e6e75..db42b391f 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -104,6 +104,8 @@ Each credential namespace is independent. For example, the CLI never combines `H The same rule holds for tokens, and for the same reason: whichever namespace supplies a token owns both halves of it. If `HARPER_CLI_OPERATION_TOKEN` were allowed to pair with `CLI_TARGET_REFRESH_TOKEN`, commands would run as the first identity until its operation token expired and then silently continue as the second. +The namespace is chosen by which one is **set**, not by which one has a usable value — so `HARPER_CLI_REFRESH_TOKEN=` (present but empty) claims the choice and shadows a perfectly good `CLI_TARGET_REFRESH_TOKEN`, which is never consulted. The run then falls through to the saved login token. Unset the preferred variable rather than blanking it. + For a pipeline, prefer a token over `HARPER_CLI_PASSWORD`: it is scoped to authentication, it can be revoked without changing the account password, and it cannot be used to log in interactively. See [Token credentials for CI/CD](#token-credentials-for-cicd). **Example `.env` file**: @@ -206,7 +208,7 @@ Expose the two values to the deploy step and no other credentials are needed: HARPER_CLI_REFRESH_TOKEN: ${{ secrets.HARPER_CLI_REFRESH_TOKEN }} ``` -**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. If the refresh token itself is rejected, the command reports that and exits non-zero rather than falling back to another identity. +**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. A refresh token the server rejects as expired or invalid (401) stops the command with a non-zero exit and a "run harper login again" message. Any _other_ refresh failure — a 5xx, a timeout, a connection error — is only reported: the command carries on with no bearer token, so it either fails as unauthenticated or, if it happens to carry `username=` and `password=` operation parameters, authenticates as that pair instead. Don't rely on a refresh failure to halt a pipeline. **A blank token variable is reported, then skipped.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI warns and continues down the precedence list, so the run proceeds under the saved `harper login` token if that machine has one. The warning is the only thing separating this from silently deploying as whoever last logged in, so treat it as a failure signal in CI rather than assuming a blank secret stops the run. From f8e5064584de7183d9cee4b2470af129a5a9a590 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 13:16:06 -0400 Subject: [PATCH 05/18] docs(cli): split refresh-failure behavior by credential shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round (codex + gemini + cursor-composer, at 5bd6ec0b) sharpened the fix from the first. My correction said any non-401 refresh failure leaves the command with no bearer token. That is only true for the refresh-token-only shape. `refreshExpiredOperationToken` is entered when the operation token is absent *or* expired, and on a non-401 failure it returns without clearing what is already there. So: - Refresh token only — what `--for-ci` provisions, and the CI case — no bearer token is attached, and a payload `username=`/`password=` pair can authenticate the command as a different identity. - An expired operation token also present — that expired token is still attached, the server rejects it, and you get a 401 rather than an identity switch. Also documents that a `200` carrying no `operation_token` is not reported at all, so a clean exit is not evidence the intended identity ran. The 5.2 release note claimed a configured CI identity is authoritative on a runner that also has a developer's login. True for precedence, not for this failure mode; qualified accordingly. Both legs converged on the same gap from different angles, which is why it is worth the extra paragraph rather than a parenthetical. --- reference/cli/authentication.md | 9 ++++++++- release-notes/v5-lincoln/5.2.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index db42b391f..a7650a85f 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -208,7 +208,14 @@ Expose the two values to the deploy step and no other credentials are needed: HARPER_CLI_REFRESH_TOKEN: ${{ secrets.HARPER_CLI_REFRESH_TOKEN }} ``` -**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. A refresh token the server rejects as expired or invalid (401) stops the command with a non-zero exit and a "run harper login again" message. Any _other_ refresh failure — a 5xx, a timeout, a connection error — is only reported: the command carries on with no bearer token, so it either fails as unauthenticated or, if it happens to carry `username=` and `password=` operation parameters, authenticates as that pair instead. Don't rely on a refresh failure to halt a pipeline. +**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. A refresh token the server rejects as expired or invalid (401) stops the command with a non-zero exit and a "run harper login again" message. + +Any _other_ refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: + +- **Refresh token only** (what `--for-ci` provisions): no bearer token is attached. The command either fails as unauthenticated or, if it also carries `username=` and `password=` operation parameters, authenticates as that pair instead — a different identity than the one you configured. +- **An expired operation token as well**: that expired token is still attached, so the request goes out carrying it and the server rejects it. You get a 401 rather than a silent identity switch. + +A `200` response that contains no `operation_token` is not reported at all. So do not rely on a refresh failure to halt a pipeline, and do not read a successful exit as proof that the identity you configured is the one that ran. **A blank token variable is reported, then skipped.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI warns and continues down the precedence list, so the run proceeds under the saved `harper login` token if that machine has one. The warning is the only thing separating this from silently deploying as whoever last logged in, so treat it as a failure signal in CI rather than assuming a blank secret stops the run. diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 3cf2ee328..0b8f37294 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -60,7 +60,7 @@ CLI Operations API commands now accept dedicated `auth_username=` and `auth_pass ### Token Credentials for CI/CD -A pipeline no longer needs an admin password. `HARPER_CLI_REFRESH_TOKEN` supplies a long-lived token that the CLI trades for a fresh, short-lived operation token on every run, and `HARPER_CLI_OPERATION_TOKEN` supplies one directly for callers that mint their own. Both rank above a saved `harper login` token and above the legacy `username=`/`password=` fallback, so a configured CI identity is authoritative on a runner that also has a developer's login. A token refreshed from an environment variable is held in memory for that invocation only. +A pipeline no longer needs an admin password. `HARPER_CLI_REFRESH_TOKEN` supplies a long-lived token that the CLI trades for a fresh, short-lived operation token on every run, and `HARPER_CLI_OPERATION_TOKEN` supplies one directly for callers that mint their own. Both rank above a saved `harper login` token and above the legacy `username=`/`password=` fallback, so a configured CI identity is authoritative on a runner that also has a developer's login — as long as the token resolves. If a refresh call fails with anything other than a 401, the command continues without a bearer token, and a payload `username=`/`password=` pair can then authenticate it as someone else. A token refreshed from an environment variable is held in memory for that invocation only. `harper login --for-ci` provisions them: it prints `HARPER_CLI_TARGET` and `HARPER_CLI_REFRESH_TOKEN` to stdout in `.env` format and nothing else, so `harper login --for-ci | gh secret set --env-file -` stores both without the token being displayed. From 82412b6ba5b81cafaff096b03fcaf93da31a6dfc Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 13:18:08 -0400 Subject: [PATCH 06/18] docs(release-notes): scope the refresh-failure caveat to the CI credential shape The 5.2 note applied the no-bearer-token fallback to every non-401 refresh failure, which over-generalizes in the same way the reference page did before it was split: with an expired operation token also present, that token is still attached and the server 401s instead. Scoped to the refresh-token-only shape, which is what `--for-ci` provisions and what the note is about. Third review round; the findings have narrowed each time (major, then two minors, then one), which is convergence rather than a moving target. --- release-notes/v5-lincoln/5.2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 0b8f37294..aaf57fc55 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -60,7 +60,7 @@ CLI Operations API commands now accept dedicated `auth_username=` and `auth_pass ### Token Credentials for CI/CD -A pipeline no longer needs an admin password. `HARPER_CLI_REFRESH_TOKEN` supplies a long-lived token that the CLI trades for a fresh, short-lived operation token on every run, and `HARPER_CLI_OPERATION_TOKEN` supplies one directly for callers that mint their own. Both rank above a saved `harper login` token and above the legacy `username=`/`password=` fallback, so a configured CI identity is authoritative on a runner that also has a developer's login — as long as the token resolves. If a refresh call fails with anything other than a 401, the command continues without a bearer token, and a payload `username=`/`password=` pair can then authenticate it as someone else. A token refreshed from an environment variable is held in memory for that invocation only. +A pipeline no longer needs an admin password. `HARPER_CLI_REFRESH_TOKEN` supplies a long-lived token that the CLI trades for a fresh, short-lived operation token on every run, and `HARPER_CLI_OPERATION_TOKEN` supplies one directly for callers that mint their own. Both rank above a saved `harper login` token and above the legacy `username=`/`password=` fallback, so a configured CI identity is authoritative on a runner that also has a developer's login — as long as the token resolves. If a refresh call fails with anything other than a 401 and the refresh token was the only credential supplied — the shape `--for-ci` provisions — the command continues without a bearer token, and a payload `username=`/`password=` pair can then authenticate it as someone else. A token refreshed from an environment variable is held in memory for that invocation only. `harper login --for-ci` provisions them: it prints `HARPER_CLI_TARGET` and `HARPER_CLI_REFRESH_TOKEN` to stdout in `.env` format and nothing else, so `harper login --for-ci | gh secret set --env-file -` stores both without the token being displayed. From 4c1da6c798e561cfcf150b11416e0ae88c814040 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 13:47:24 -0400 Subject: [PATCH 07/18] docs(cli): expiry answers 403 and does not halt the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain adjudication ran for the first time this session — the `claude` CLI's OAuth token had expired, which is why five earlier rounds reported an opaque `exit-1`. Its first pass found a major the four codex/gemini rounds had missed. **An expired refresh token does not stop the command.** `validateRefreshToken` maps `TokenExpiredError` to `FORBIDDEN` (403) and everything else invalid to `UNAUTHORIZED` (401); the CLI halts on 401 only. So expiry — the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses — takes the continue path, and in the refresh-token-only shape that means no bearer token and a payload `username=`/`password=` pair authenticating instead. The page promised a non-zero exit for exactly the failure a pipeline is most likely to hit. Now a warning, with the advice not to build a day-31 runbook around an exit code. Three more, all verified before changing the text: - **`--for-ci`'s "never appears on screen" was unconditional and is not.** The guard is on `process.stdin.isTTY` (an interactive confirmation before rotating that user's token); there is none on stdout, so a bare invocation prints the refresh token into scrollback. Qualified to the piped case and made "pipe it" explicit. - **The local-socket carve-out said what is ignored, never what authenticates.** On a self-hosted runner an unset or blank `target` does not fail — it resolves to the local instance and runs as superuser on the socket's ambient trust. A job that loses `HARPER_CLI_TARGET` deploys to the runner's own node. - **Precedence rank 5 taught the wrong fallback model.** Ranks 3 and 4 are a real two-step fallback; the token namespaces are not, since presence alone shadows. An operator generalizing from 3/4 would blank the preferred variable expecting the legacy one to be read. Also corrects `reference/configuration/options.md`, which gave `refreshTokenTimeout` a `1d` default while its own YAML example above said `30d` and `config/configUtils.ts` defaults to `30d`. Pre-existing and self-contradictory within one file; fixed while CI token-rotation planning depends on the number. --- reference/cli/authentication.md | 16 ++++++++++++---- reference/configuration/options.md | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index a7650a85f..7b8f10a5f 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -48,7 +48,9 @@ For remote Operations API commands, the CLI uses the first complete authenticati Credentials are resolved as a pair and are never combined across sources. An incomplete pair supplied with dedicated authentication parameters or in the target URL causes the command to fail. An incomplete environment-variable pair is skipped with a warning so that a saved login token can still be used. -Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. +Entry 5 is not a two-step fallback the way 3 and 4 are: whichever token namespace is merely **set** claims the choice, so a blank `HARPER_CLI_REFRESH_TOKEN` shadows a complete `CLI_TARGET_REFRESH_TOKEN` instead of deferring to it — see below. + +Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail, it resolves to the local instance and runs as superuser on the socket's ambient trust, with no credential checked at all. A job that loses its `HARPER_CLI_TARGET` deploys to the runner's own node rather than erroring. Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`. @@ -196,7 +198,7 @@ HARPER_CLI_TARGET=https://example.com:9925/ HARPER_CLI_REFRESH_TOKEN=eyJhbGciOi... ``` -Because stdout carries only these two lines, the token never appears on screen or in your shell history — which is not true of copying it out of terminal output by hand. If the cluster returns no refresh token, the command fails rather than emitting a half-block that would "succeed" at storing nothing. +Because stdout carries only these two lines, piping it keeps the token off your screen and out of your shell history — which is not true of copying it out of terminal output by hand. **Pipe it.** There is no guard on a terminal stdout, so running `harper login --for-ci` bare prints the refresh token into your scrollback, where the terminal may persist it. (Interactively the command does first ask you to confirm minting for that user, since doing so revokes any token the user already holds; that prompt is skipped when stdin is not a TTY.) If the cluster returns no refresh token, the command fails rather than emitting a half-block that would "succeed" at storing nothing. Expose the two values to the deploy step and no other credentials are needed: @@ -208,9 +210,15 @@ Expose the two values to the deploy step and no other credentials are needed: HARPER_CLI_REFRESH_TOKEN: ${{ secrets.HARPER_CLI_REFRESH_TOKEN }} ``` -**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. A refresh token the server rejects as expired or invalid (401) stops the command with a non-zero exit and a "run harper login again" message. +**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. A refresh token the server rejects as **malformed or unrecognized** answers `401`, and the CLI stops with a non-zero exit and a "run harper login again" message. + +:::warning +**An expired refresh token does not stop the command.** Harper answers expiry with `403`, not `401` — `validateRefreshToken` maps `TokenExpiredError` to `FORBIDDEN` — and the CLI's halt branch keys on `401` alone. Expiry is the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses, so this is the failure a pipeline is most likely to meet, and it takes the continue path below rather than halting. + +Do not build a runbook around a non-zero exit at day 31. Watch the operation's own result instead, and keep payload `username=`/`password=` off any command you expect a token to authenticate. +::: -Any _other_ refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: +A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: - **Refresh token only** (what `--for-ci` provisions): no bearer token is attached. The command either fails as unauthenticated or, if it also carries `username=` and `password=` operation parameters, authenticates as that pair instead — a different identity than the one you configured. - **An expired operation token as well**: that expired token is still attached, so the request goes out carrying it and the server rejects it. You get a 401 rather than a silent identity switch. diff --git a/reference/configuration/options.md b/reference/configuration/options.md index e8fdf3c77..72cd49228 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -107,7 +107,7 @@ authentication: - `cacheTTL` — Session cache duration (ms); _Default_: `30000` - `enableSessions` — Cookie-based sessions; _Default_: `true` - `operationTokenTimeout` — Access token lifetime; _Default_: `1d` -- `refreshTokenTimeout` — Refresh token lifetime; _Default_: `1d` +- `refreshTokenTimeout` — Refresh token lifetime; _Default_: `30d` - `logging` — Authentication event logging (Added in: v4.6.0); sub-options: `path`, `level`, `tag`, `stdStreams`. See [Logging Configuration](../logging/configuration.md) --- From a8b252d396a8fa41c92245ac40463d437755a76c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 13:54:50 -0400 Subject: [PATCH 08/18] docs(cli): a lost target falls back to the saved login, not straight to local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjudicated round at 4c1da6c7. My local-socket sentence had the blast radius backwards: `resolveTarget` is `req.target || HARPER_CLI_TARGET || CLI_TARGET || last_target`, so a job that loses its environment target falls back to whatever remote that machine last logged into, and only reaches the local socket if there is no saved target either. I documented the harmless case and omitted the wider one — a runner that has ever been `harper login`-ed deploys to that remote. Also trims prose the adjudicator flagged in two consecutive rounds as narrating implementation history rather than stating the operator-facing contract: the non-mixing rule no longer argues from a hypothetical that cannot occur, the two editorial closers are folded into the sentences that already carry the fact, and the 90-word release-note run-on is replaced by a pointer to the reference section that owns the detail. --- reference/cli/authentication.md | 8 ++++---- release-notes/v5-lincoln/5.2.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 7b8f10a5f..f5522d8f4 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -50,7 +50,7 @@ Credentials are resolved as a pair and are never combined across sources. An inc Entry 5 is not a two-step fallback the way 3 and 4 are: whichever token namespace is merely **set** claims the choice, so a blank `HARPER_CLI_REFRESH_TOKEN` shadows a complete `CLI_TARGET_REFRESH_TOKEN` instead of deferring to it — see below. -Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail, it resolves to the local instance and runs as superuser on the socket's ambient trust, with no credential checked at all. A job that loses its `HARPER_CLI_TARGET` deploys to the runner's own node rather than erroring. +Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius. Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`. @@ -104,7 +104,7 @@ Starting in v5.2.0, a complete environment-variable credential pair takes preced Each credential namespace is independent. For example, the CLI never combines `HARPER_CLI_USERNAME` with `CLI_TARGET_PASSWORD`. If either namespace supplies only a username or only a password, that incomplete pair is skipped with a warning. -The same rule holds for tokens, and for the same reason: whichever namespace supplies a token owns both halves of it. If `HARPER_CLI_OPERATION_TOKEN` were allowed to pair with `CLI_TARGET_REFRESH_TOKEN`, commands would run as the first identity until its operation token expired and then silently continue as the second. +The same rule holds for tokens: whichever namespace supplies a token owns both halves of it, so an operation token from one namespace is never paired with a refresh token from the other. The namespace is chosen by which one is **set**, not by which one has a usable value — so `HARPER_CLI_REFRESH_TOKEN=` (present but empty) claims the choice and shadows a perfectly good `CLI_TARGET_REFRESH_TOKEN`, which is never consulted. The run then falls through to the saved login token. Unset the preferred variable rather than blanking it. @@ -223,9 +223,9 @@ A `403`, and any other refresh failure — a 5xx, a timeout, a connection error - **Refresh token only** (what `--for-ci` provisions): no bearer token is attached. The command either fails as unauthenticated or, if it also carries `username=` and `password=` operation parameters, authenticates as that pair instead — a different identity than the one you configured. - **An expired operation token as well**: that expired token is still attached, so the request goes out carrying it and the server rejects it. You get a 401 rather than a silent identity switch. -A `200` response that contains no `operation_token` is not reported at all. So do not rely on a refresh failure to halt a pipeline, and do not read a successful exit as proof that the identity you configured is the one that ran. +A `200` response that contains no `operation_token` is not reported at all. A refresh failure therefore does not reliably halt a pipeline, and a zero exit is not proof that the identity you configured is the one that ran. -**A blank token variable is reported, then skipped.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI warns and continues down the precedence list, so the run proceeds under the saved `harper login` token if that machine has one. The warning is the only thing separating this from silently deploying as whoever last logged in, so treat it as a failure signal in CI rather than assuming a blank secret stops the run. +**A blank token variable is reported, then skipped.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI warns and continues down the precedence list, so the run proceeds under the saved `harper login` token if that machine has one. Treat that warning as a CI failure signal: a blank secret does not stop the run, it changes which identity performs it. **Lifetimes.** Operation tokens expire after `authentication.operationTokenTimeout` (default `1d`) and refresh tokens after `authentication.refreshTokenTimeout` (default `30d`). The pipeline needs a new refresh token when that window closes. diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index aaf57fc55..c2b5c6b94 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -60,7 +60,7 @@ CLI Operations API commands now accept dedicated `auth_username=` and `auth_pass ### Token Credentials for CI/CD -A pipeline no longer needs an admin password. `HARPER_CLI_REFRESH_TOKEN` supplies a long-lived token that the CLI trades for a fresh, short-lived operation token on every run, and `HARPER_CLI_OPERATION_TOKEN` supplies one directly for callers that mint their own. Both rank above a saved `harper login` token and above the legacy `username=`/`password=` fallback, so a configured CI identity is authoritative on a runner that also has a developer's login — as long as the token resolves. If a refresh call fails with anything other than a 401 and the refresh token was the only credential supplied — the shape `--for-ci` provisions — the command continues without a bearer token, and a payload `username=`/`password=` pair can then authenticate it as someone else. A token refreshed from an environment variable is held in memory for that invocation only. +A pipeline no longer needs an admin password. `HARPER_CLI_REFRESH_TOKEN` supplies a long-lived token that the CLI trades for a fresh, short-lived operation token on every run, and `HARPER_CLI_OPERATION_TOKEN` supplies one directly for callers that mint their own. Both rank above a saved `harper login` token and above the legacy `username=`/`password=` fallback, so a configured CI identity is authoritative on a runner that also has a developer's login — as long as the token resolves. It does not always fail closed when it doesn't: see [Token credentials for CI/CD](/reference/v5/cli/authentication#token-credentials-for-cicd). A token refreshed from an environment variable is held in memory for that invocation only. `harper login --for-ci` provisions them: it prints `HARPER_CLI_TARGET` and `HARPER_CLI_REFRESH_TOKEN` to stdout in `.env` format and nothing else, so `harper login --for-ci | gh secret set --env-file -` stores both without the token being displayed. From 8b2c9ea464caee066eecda0a5ee69ef9d3ecfeb7 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:02:02 -0400 Subject: [PATCH 09/18] docs(cli): state the one-credential-style rule once, up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review direction: guide readers to pick a single credential style rather than leaving them to combine a token with raw credentials. Checking the existing text rather than assuming it already said this — it did not, quite. Line 111 said "prefer a token over `HARPER_CLI_PASSWORD`", which is a preference between two *environment* credentials and silent on the payload `username=`/`password=` pair. The part that matters, keeping payload credentials off a command a token is meant to authenticate, sat inside the 403 warning, so it only reached a reader who opened that box. Now a single rule directly under the precedence list, where it belongs: precedence exists to resolve a conflict and resolves it silently, and every bad outcome this page documents needs two styles live at once — a payload pair taking over when a token stops resolving, a blank token variable handing the run to a saved login. A small table maps context to style (pipeline / one-off admin command / local development). The 403 warning and the pipeline advice now defer to the rule instead of each carrying a partial version of it. Also drops a `#workload-identity-oidc` link I had reached for out of habit: that section lives on #599, not this branch, and the build caught it as a broken anchor. Back to the two pre-existing anchors on main. --- reference/cli/authentication.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index f5522d8f4..674fc657f 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -46,6 +46,18 @@ For remote Operations API commands, the CLI uses the first complete authenticati 6. A token saved by `harper login` 7. `username=` and `password=` operation parameters (legacy fallback) +:::tip +**Configure one credential style per context, not two.** Precedence exists to resolve a conflict, but it resolves it silently, and the ways this page describes for authentication to go wrong all need two styles live at once: a payload `username=`/`password=` pair takes over when a token stops resolving, a blank token variable hands the run to whatever saved login the machine has. Pick one and leave the others unset: + +| Context | Use | +| ----------------------- | ----------------------------------- | +| CI/CD pipeline | `HARPER_CLI_REFRESH_TOKEN` | +| A one-off admin command | `auth_username=` / `auth_password=` | +| Local development | `harper login` | + +Setting a token _and_ leaving `username=`/`password=` on the command is the combination that turns a token failure into an identity change rather than an error. +::: + Credentials are resolved as a pair and are never combined across sources. An incomplete pair supplied with dedicated authentication parameters or in the target URL causes the command to fail. An incomplete environment-variable pair is skipped with a warning so that a saved login token can still be used. Entry 5 is not a two-step fallback the way 3 and 4 are: whichever token namespace is merely **set** claims the choice, so a blank `HARPER_CLI_REFRESH_TOKEN` shadows a complete `CLI_TARGET_REFRESH_TOKEN` instead of deferring to it — see below. @@ -108,7 +120,7 @@ The same rule holds for tokens: whichever namespace supplies a token owns both h The namespace is chosen by which one is **set**, not by which one has a usable value — so `HARPER_CLI_REFRESH_TOKEN=` (present but empty) claims the choice and shadows a perfectly good `CLI_TARGET_REFRESH_TOKEN`, which is never consulted. The run then falls through to the saved login token. Unset the preferred variable rather than blanking it. -For a pipeline, prefer a token over `HARPER_CLI_PASSWORD`: it is scoped to authentication, it can be revoked without changing the account password, and it cannot be used to log in interactively. See [Token credentials for CI/CD](#token-credentials-for-cicd). +For a pipeline, a token is the right style: it is scoped to authentication, it can be revoked without changing the account password, and it cannot be used to log in interactively. Use it _instead of_ a password or payload credentials, not alongside them — see [Token credentials for CI/CD](#token-credentials-for-cicd). **Example `.env` file**: @@ -215,7 +227,7 @@ Expose the two values to the deploy step and no other credentials are needed: :::warning **An expired refresh token does not stop the command.** Harper answers expiry with `403`, not `401` — `validateRefreshToken` maps `TokenExpiredError` to `FORBIDDEN` — and the CLI's halt branch keys on `401` alone. Expiry is the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses, so this is the failure a pipeline is most likely to meet, and it takes the continue path below rather than halting. -Do not build a runbook around a non-zero exit at day 31. Watch the operation's own result instead, and keep payload `username=`/`password=` off any command you expect a token to authenticate. +Do not build a runbook around a non-zero exit at day 31. Watch the operation's own result instead. This is the failure the one-style rule above exists to contain: with no payload credentials on the command, an expired token fails visibly instead of silently changing identity. ::: A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: From 9fa2436e1395c0737e20b89a93fe8d32b4899f9a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:05:59 -0400 Subject: [PATCH 10/18] docs(cli): point the 403 warning at its tracking issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filed as harper#2297 (Bug, P2, v5.3). Two changes follow from that: The warning now says the behavior is a defect rather than a property of the product, and names the issue — so a reader knows it is expected to change, and whoever fixes it has a breadcrumb back to the doc that has to be removed. It also drops the internal symbol names (`validateRefreshToken`, `TokenExpiredError`). The adjudicator flagged that as trading durability for reviewer confidence, and it was right: they were there to show the claim was source-derived, which the issue link now does better. Observable status codes are what a reader can act on. --- reference/cli/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 674fc657f..7a66500ba 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -225,7 +225,7 @@ Expose the two values to the deploy step and no other credentials are needed: **Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. A refresh token the server rejects as **malformed or unrecognized** answers `401`, and the CLI stops with a non-zero exit and a "run harper login again" message. :::warning -**An expired refresh token does not stop the command.** Harper answers expiry with `403`, not `401` — `validateRefreshToken` maps `TokenExpiredError` to `FORBIDDEN` — and the CLI's halt branch keys on `401` alone. Expiry is the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses, so this is the failure a pipeline is most likely to meet, and it takes the continue path below rather than halting. +**An expired refresh token does not stop the command.** Harper answers expiry with `403`, not `401`, and the CLI's halt branch keys on `401` alone. This is a defect rather than intended behavior, tracked as [harper#2297](https://github.com/HarperFast/harper/issues/2297); this note should come out when it is fixed. Expiry is the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses, so this is the failure a pipeline is most likely to meet, and it takes the continue path below rather than halting. Do not build a runbook around a non-zero exit at day 31. Watch the operation's own result instead. This is the failure the one-style rule above exists to contain: with no payload credentials on the command, an expired token fails visibly instead of silently changing identity. ::: From f0d42cac113982468b8a70889d8c551b677906d4 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:15:43 -0400 Subject: [PATCH 11/18] docs(cli): loopback targets make a token failure succeed, not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final round, all three findings verified in source before editing. **major — an expired token against a loopback target does not fail, it succeeds as superuser.** `authentication.authorizeLocal` defaults to `true` (config-root.schema.json), and `security/auth.ts` grants `getSuperUser()` to any request from `127.0.0.` or `::1` with no credential checked. So on a self-hosted runner pointed at its own node, a refresh-token-only expiry produces a green, fully privileged run rather than an error — the failure mode least likely to be noticed, and the one the page did not cover. My earlier local-trust note was also too narrow: it credited the domain socket, when loopback TCP is trusted the same way by default. **minor — an expired operation token answers 403, not 401.** `validateOperationToken` and `validateRefreshToken` both delegate to the shared `validateToken`, whose catch maps `TokenExpiredError` to `FORBIDDEN`. I had carried the 401 over from the malformed-token case, which would have sent someone alarming on the wrong status. **minor — `pbcopy` was offered as equivalent to a secret store and is not.** The "off your screen and out of your shell history" reasoning holds for `gh secret set`; a clipboard-history tool keeps a copy on disk, and since a user holds exactly one refresh token, that copy is the pipeline's live credential. The example is removed from both pages that carried it (it came from the CLI's own docstring, so it is worth not propagating) and the claim is scoped to piping into something that will store it. --- reference/cli/authentication.md | 12 +++++------- reference/cli/commands.md | 3 --- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 7a66500ba..5e9617ca0 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -62,7 +62,7 @@ Credentials are resolved as a pair and are never combined across sources. An inc Entry 5 is not a two-step fallback the way 3 and 4 are: whichever token namespace is merely **set** claims the choice, so a blank `HARPER_CLI_REFRESH_TOKEN` shadows a complete `CLI_TARGET_REFRESH_TOKEN` instead of deferring to it — see below. -Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius. +Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts — as, by default, it trusts any loopback address, since `authentication.authorizeLocal` defaults to `true`. Token environment variables are deliberately ignored on the socket path — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius. Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`. @@ -198,9 +198,6 @@ Rather than storing an admin password in your CI provider, log in once locally a ```bash # Set both GitHub Actions secrets in one command — the token is never displayed harper login --for-ci | gh secret set --env-file - - -# Or copy them to the clipboard to paste in by hand -harper login --for-ci | pbcopy ``` The block it emits: @@ -210,7 +207,7 @@ HARPER_CLI_TARGET=https://example.com:9925/ HARPER_CLI_REFRESH_TOKEN=eyJhbGciOi... ``` -Because stdout carries only these two lines, piping it keeps the token off your screen and out of your shell history — which is not true of copying it out of terminal output by hand. **Pipe it.** There is no guard on a terminal stdout, so running `harper login --for-ci` bare prints the refresh token into your scrollback, where the terminal may persist it. (Interactively the command does first ask you to confirm minting for that user, since doing so revokes any token the user already holds; that prompt is skipped when stdin is not a TTY.) If the cluster returns no refresh token, the command fails rather than emitting a half-block that would "succeed" at storing nothing. +Because stdout carries only these two lines, piping it into a secret store keeps the token off your screen and out of your shell history. Piping it to the clipboard does not: a clipboard-history tool will keep a copy on disk, and since a user holds exactly one refresh token at a time, that copy is the pipeline's live credential. Pipe it to the thing that will store it — which is not true of copying it out of terminal output by hand. **Pipe it.** There is no guard on a terminal stdout, so running `harper login --for-ci` bare prints the refresh token into your scrollback, where the terminal may persist it. (Interactively the command does first ask you to confirm minting for that user, since doing so revokes any token the user already holds; that prompt is skipped when stdin is not a TTY.) If the cluster returns no refresh token, the command fails rather than emitting a half-block that would "succeed" at storing nothing. Expose the two values to the deploy step and no other credentials are needed: @@ -232,8 +229,9 @@ Do not build a runbook around a non-zero exit at day 31. Watch the operation's o A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: -- **Refresh token only** (what `--for-ci` provisions): no bearer token is attached. The command either fails as unauthenticated or, if it also carries `username=` and `password=` operation parameters, authenticates as that pair instead — a different identity than the one you configured. -- **An expired operation token as well**: that expired token is still attached, so the request goes out carrying it and the server rejects it. You get a 401 rather than a silent identity switch. +- **Refresh token only** (what `--for-ci` provisions): no bearer token is attached. Against a remote target the command fails as unauthenticated — unless it also carries `username=` and `password=` operation parameters, in which case it authenticates as that pair instead, a different identity than the one you configured. +- **Refresh token only, against a loopback target**: it does not fail at all. `authentication.authorizeLocal` defaults to `true`, so a request from `127.0.0.1` or `::1` is authenticated as **superuser** with no credential checked. On a self-hosted runner pointed at its own node, an expired token therefore produces a green, fully privileged run rather than an error. This is the failure mode least likely to be noticed. +- **An expired operation token as well**: that expired token is still attached, so the request goes out carrying it and the server rejects it with `403` — token expiry answers `403` on the operations path too, since both token types are checked by the same validator. You get a rejection rather than a silent identity switch, but do not alarm on `401` for it. A `200` response that contains no `operation_token` is not reported at all. A refresh failure therefore does not reliably halt a pipeline, and a zero exit is not proof that the identity you configured is the one that ran. diff --git a/reference/cli/commands.md b/reference/cli/commands.md index 23e771c78..6c658460a 100644 --- a/reference/cli/commands.md +++ b/reference/cli/commands.md @@ -174,9 +174,6 @@ Prints `HARPER_CLI_TARGET` and `HARPER_CLI_REFRESH_TOKEN` to **stdout** in `.env ```bash # Set both GitHub Actions secrets in one command harper login --for-ci | gh secret set --env-file - - -# Or copy them to paste in by hand -harper login --for-ci | pbcopy ``` Run this as a **dedicated CI user**, not your own account: a user holds only one valid refresh token at a time, so issuing one for CI revokes any other token that user already had. See [Token credentials for CI/CD](authentication.md#token-credentials-for-cicd) for how the CLI consumes these variables and where they sit in authentication precedence. From f34693fea692dc33490a67fcf04895a6a5cc3b07 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:20:24 -0400 Subject: [PATCH 12/18] docs(cli): resolve the contradiction the loopback bullet created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adjudicator caught that my previous commit fixed the failure list but not the containment promise above it, so the page said two opposite things about one failure. Line 227 claimed that dropping payload credentials makes an expired token "fail visibly", while the new bullet says a loopback target returns a green, fully privileged run under exactly those conditions. The callout is where a hurried reader stops, so the qualifier belongs there rather than three paragraphs down. The one-style tip had the same overreach: it now says the rule bounds *which* credential is used, not whether one is required, because against a loopback target Harper authenticates by address and no choice of style helps. Worth noting the adjudicator reversed its own prior ruling here — it had dropped this finding last round on the reasoning that loopback HTTP authenticates normally, then verified `authorizeLocal` defaults to `true` and said so. Two more from the same round: - **"Revoke its access" named no procedure, and none exists.** There is no revoke operation: `harper logout` only deletes the local copy and leaves the server-side hash valid. The remediation is superseding the hash by minting again as that user, or deactivating the user with `alter_user`. A responder to a leaked token would otherwise have gone looking for a command that isn't there. - **`CLI_TARGET_OPERATION_TOKEN` was implied but never named.** Entry 5 says "legacy `CLI_TARGET_` equivalents" plural while the variable list gave only the refresh one, and the owns-both-halves rule makes the answer load-bearing. --- reference/cli/authentication.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 5e9617ca0..f716a1d7b 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -55,7 +55,7 @@ For remote Operations API commands, the CLI uses the first complete authenticati | A one-off admin command | `auth_username=` / `auth_password=` | | Local development | `harper login` | -Setting a token _and_ leaving `username=`/`password=` on the command is the combination that turns a token failure into an identity change rather than an error. +Setting a token _and_ leaving `username=`/`password=` on the command is the combination that turns a token failure into an identity change rather than an error. One caveat: this rule bounds _which_ credential is used, not whether one is required at all. Against a loopback target Harper authenticates by address, so no choice of style protects you there — see the refresh-behavior note below. ::: Credentials are resolved as a pair and are never combined across sources. An incomplete pair supplied with dedicated authentication parameters or in the target URL causes the command to fail. An incomplete environment-variable pair is skipped with a warning so that a saved login token can still be used. @@ -112,7 +112,7 @@ Starting in v5.2.0, a complete environment-variable credential pair takes preced - `HARPER_CLI_USERNAME` and `HARPER_CLI_PASSWORD` - Preferred credential pair for the target. - `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` - Lower-priority legacy credential pair. - `HARPER_CLI_REFRESH_TOKEN` - Long-lived token the CLI exchanges for a fresh operation token on each run. `CLI_TARGET_REFRESH_TOKEN` is the legacy equivalent. -- `HARPER_CLI_OPERATION_TOKEN` - A short-lived operation token supplied directly, for callers that mint their own. +- `HARPER_CLI_OPERATION_TOKEN` - A short-lived operation token supplied directly, for callers that mint their own. `CLI_TARGET_OPERATION_TOKEN` is the legacy equivalent. Each credential namespace is independent. For example, the CLI never combines `HARPER_CLI_USERNAME` with `CLI_TARGET_PASSWORD`. If either namespace supplies only a username or only a password, that incomplete pair is skipped with a warning. @@ -224,7 +224,7 @@ Expose the two values to the deploy step and no other credentials are needed: :::warning **An expired refresh token does not stop the command.** Harper answers expiry with `403`, not `401`, and the CLI's halt branch keys on `401` alone. This is a defect rather than intended behavior, tracked as [harper#2297](https://github.com/HarperFast/harper/issues/2297); this note should come out when it is fixed. Expiry is the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses, so this is the failure a pipeline is most likely to meet, and it takes the continue path below rather than halting. -Do not build a runbook around a non-zero exit at day 31. Watch the operation's own result instead. This is the failure the one-style rule above exists to contain: with no payload credentials on the command, an expired token fails visibly instead of silently changing identity. +Do not build a runbook around a non-zero exit at day 31. Watch the operation's own result instead — and note that on a **loopback target the operation succeeds**, because `authentication.authorizeLocal` defaults to `true` and grants superuser to any request from `127.0.0.1` or `::1`. Removing payload credentials makes expiry fail visibly against a _remote_ target; it does not help against a local one, where the run comes back green and fully privileged. A self-hosted runner deploying to its own node needs a check on the operation's result, not on its exit code. ::: A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: @@ -242,7 +242,9 @@ A `200` response that contains no `operation_token` is not reported at all. A re :::warning **Each user holds only one valid refresh token at a time.** Harper stores a single refresh-token hash per user, so minting a new one revokes that user's previous token. A routine local `harper login` as the same account will break a pipeline holding the older token, and the failure only surfaces on the pipeline's next refresh. -Create a **dedicated CI user** and run `harper login --for-ci` as that user. That scopes the pipeline's permissions to what it actually needs, and lets you revoke its access without disturbing anyone else. +Create a **dedicated CI user** and run `harper login --for-ci` as that user. That scopes the pipeline's permissions to what it actually needs, and confines the blast radius of a leak to that account. + +There is no operation that revokes a refresh token directly. To invalidate one, either run `harper login --for-ci` again as that user — minting overwrites the stored hash, so the previous token stops working — or deactivate the user with `alter_user`. `harper logout` is not a revocation: it deletes your local copy and leaves the server-side hash valid. ::: #### Method 3: Dedicated Authentication Parameters From 8f5c88234cda5f4541aef8aa66fef23816f70445 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:23:40 -0400 Subject: [PATCH 13/18] docs(cli): the loopback case needs configuration, not a result check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both outside lenses converged on this and were right: the remediation I gave was disproved by the sentence before it. The paragraph established that a loopback run "comes back green and fully privileged", then told the reader to check the operation's result — but a superuser-authorized run produces a successful result, so the check cannot distinguish it from a correctly authenticated one. An operator following that line would have added an assertion, called the pipeline monitored, and learned nothing at day 31. There is no monitoring answer for this case, because nothing fails. The advice is configuration: do not point CI at loopback, or set `authentication.authorizeLocal: false` on a node a runner can reach. Verified that recommendation is safe before giving it — the domain-socket clause in `security/auth.ts` carries no `AUTHORIZE_LOCAL` guard, so turning the flag off removes loopback-TCP superuser without breaking local `harper` commands. Said so, since an operator will otherwise assume it does. --- reference/cli/authentication.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index f716a1d7b..36f80f1a8 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -224,7 +224,11 @@ Expose the two values to the deploy step and no other credentials are needed: :::warning **An expired refresh token does not stop the command.** Harper answers expiry with `403`, not `401`, and the CLI's halt branch keys on `401` alone. This is a defect rather than intended behavior, tracked as [harper#2297](https://github.com/HarperFast/harper/issues/2297); this note should come out when it is fixed. Expiry is the guaranteed end state of every `--for-ci` token once `refreshTokenTimeout` elapses, so this is the failure a pipeline is most likely to meet, and it takes the continue path below rather than halting. -Do not build a runbook around a non-zero exit at day 31. Watch the operation's own result instead — and note that on a **loopback target the operation succeeds**, because `authentication.authorizeLocal` defaults to `true` and grants superuser to any request from `127.0.0.1` or `::1`. Removing payload credentials makes expiry fail visibly against a _remote_ target; it does not help against a local one, where the run comes back green and fully privileged. A self-hosted runner deploying to its own node needs a check on the operation's result, not on its exit code. +Do not build a runbook around a non-zero exit at day 31 — watch the operation's own result. That is sufficient for a **remote** target, where removing payload credentials makes expiry fail visibly. + +It is not sufficient for a **loopback** target. `authentication.authorizeLocal` defaults to `true` and grants superuser to any request from `127.0.0.1` or `::1`, so an expired token there produces a genuinely successful, fully privileged run — indistinguishable from a correctly authenticated one. No check on the exit code or the result can catch it, because there is nothing failing to observe. + +The fix is configuration, not monitoring: either do not point CI at a loopback address, or set `authentication.authorizeLocal: false` on any node a runner can reach. Turning it off does not break local `harper` commands — those go over the domain socket, which is trusted by a separate rule. ::: A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: From 366b09cc76e9193a2b896cb5738c25283d074445 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:35:11 -0400 Subject: [PATCH 14/18] docs(cli): only authorizeLocal:false closes the loopback exposure, and it needs a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two majors, both invalidating the remediation in the previous commit. Fixing these despite having declared a stopping rule, because wrong security-remediation advice is worse than one more edit: a reader following it would believe an exposure was closed when it was not. **"Do not point CI at a loopback address" is not sufficient.** A same-host reverse proxy makes every forwarded request arrive from `127.0.0.1`, so `https://prod:9925` fronted by nginx on the Harper node is still a loopback peer for authorization purposes. This repo already documents that hazard at `reference/security/configuration.md` under `authorizeLocal` ("for example, when using a local proxy"), so the page I wrote contradicted our own security docs. That branch is dropped; `authorizeLocal: false` is the only sufficient answer, and the two pages are now cross-linked. **The flag needs a restart.** `AUTHORIZE_LOCAL` is a module-scope binding in `security/auth.ts`, evaluated once at load, so a live config change does not affect a running node. Omitting that would have left someone believing they had closed the hole the moment they saved the config. Also states that turning it off does not break local `harper` commands, since the domain-socket clause is a separate rule the flag does not gate — otherwise the advice reads as "break your own CLI to fix CI". --- reference/cli/authentication.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 36f80f1a8..dc25a7ff6 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -228,7 +228,11 @@ Do not build a runbook around a non-zero exit at day 31 — watch the operation' It is not sufficient for a **loopback** target. `authentication.authorizeLocal` defaults to `true` and grants superuser to any request from `127.0.0.1` or `::1`, so an expired token there produces a genuinely successful, fully privileged run — indistinguishable from a correctly authenticated one. No check on the exit code or the result can catch it, because there is nothing failing to observe. -The fix is configuration, not monitoring: either do not point CI at a loopback address, or set `authentication.authorizeLocal: false` on any node a runner can reach. Turning it off does not break local `harper` commands — those go over the domain socket, which is trusted by a separate rule. +The fix is configuration, not monitoring: set **`authentication.authorizeLocal: false`** on any node a runner can reach, and restart Harper — the flag is read once at startup, so a live config change does not take effect for jobs already running against that node. + +Choosing a non-loopback target is _not_ sufficient on its own. A same-host reverse proxy makes every forwarded request arrive from `127.0.0.1`, so `https://prod:9925` fronted by nginx on the Harper node is still a loopback peer as far as authorization is concerned. See [`authorizeLocal`](../security/configuration.md#authorizelocal), which carries the same warning for local proxies generally. + +Turning it off does not break local `harper` commands: those go over the domain socket, which is trusted by a separate rule that this flag does not gate. ::: A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: From 6e8e01b64274afa697746b8da5a280d5cea2f048 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:48:48 -0400 Subject: [PATCH 15/18] docs(cli): the socket rule is also the limit of the loopback remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two majors from the receipt round at 366b09cc, both verified in `security/auth.ts` first. **The reassurance and the residual hole are the same fact.** I wrote that turning `authorizeLocal` off does not break local `harper` commands because the domain socket is trusted by a separate rule the flag does not gate — true, and I framed it purely as comfort. That separate rule has no `AUTHORIZE_LOCAL` guard, so a reverse proxy whose upstream is the operations socket rather than a TCP port is still superuser on every request with the flag off. The remediation I gave closes the loopback-TCP path and nothing else. Now stated as a `:::danger`, with the actual boundary: do not proxy the operations socket, and treat filesystem permissions on it as the access control. **The page contradicted itself about a missing target.** Line 27, inherited from `main`, says omitting `target` "defaults to using the local domain socket connection" — absolute. The saved-target correction I added later says it falls back to the previous `harper login` target first. Both were on the page. Line 27 is the wrong one; qualified, and pointed at the precedence section. Recording plainly: this is the thirteenth round on this branch, and it is the third consecutive one to find that a remediation I wrote was insufficient in a case I had not considered. I am not confident a fourteenth converges. The security-adjacent guidance on this page wants a human who owns the deployment model, which is the ask in the PR description rather than something I can settle by re-reviewing. --- reference/cli/authentication.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index dc25a7ff6..882ed6b14 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -24,7 +24,7 @@ harper get_components harper set_configuration logging_level=info ``` -When no `target` parameter is specified, the CLI defaults to using the local domain socket connection, providing secure, authenticated access to the local Harper instance. +When no `target` parameter is specified, the CLI falls back to the target saved by a previous `harper login` if there is one, and only otherwise to the local domain socket connection, which gives authenticated access to the local Harper instance. See [Authentication Precedence](#authentication-precedence) for what that means on a shared machine. ## Remote Operations @@ -232,7 +232,11 @@ The fix is configuration, not monitoring: set **`authentication.authorizeLocal: Choosing a non-loopback target is _not_ sufficient on its own. A same-host reverse proxy makes every forwarded request arrive from `127.0.0.1`, so `https://prod:9925` fronted by nginx on the Harper node is still a loopback peer as far as authorization is concerned. See [`authorizeLocal`](../security/configuration.md#authorizelocal), which carries the same warning for local proxies generally. -Turning it off does not break local `harper` commands: those go over the domain socket, which is trusted by a separate rule that this flag does not gate. +Turning it off does not break local `harper` commands: those go over the operations API domain socket, which is trusted by a separate rule that this flag does not gate. + +:::danger +That separate rule is also the limit of the remediation. A connection arriving on the operations socket is authorized as superuser with no credential, and `authorizeLocal: false` does not change that. So if a reverse proxy's upstream is the operations socket rather than a TCP port, setting the flag closes nothing — every proxied request is still superuser. Do not expose the operations API socket through a proxy, and treat filesystem permissions on it as the access control. +::: ::: A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: From 3cd66b4398489d67a587d3dd50b6e4a4d960c011 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:49:54 -0400 Subject: [PATCH 16/18] docs(cli): scope the loopback remediation to a pointer, keep the hazard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three consecutive rounds found the remediation I wrote insufficient in a case I had not considered: first that a result check cannot detect it, then that avoiding a loopback target does not help behind a same-host proxy, then that `authorizeLocal: false` does not cover a proxy whose upstream is the operations socket. Each fix was correct and each was incomplete, which is the signal that this content is in the wrong place rather than merely underspecified. It is server security configuration. It surfaced here because a token-expiry failure exposed it, but `reference/security/configuration.md` owns `authorizeLocal` and already carries the local-proxy warning. So this page now does what a CLI page should: states the hazard a pipeline author needs — an expired token against a node with local authorization enabled produces a successful, fully privileged run, and no exit code or result check detects it — names the flag, notes that neither the flag alone nor a non-loopback target is sufficient, and sends the reader to the page that owns the full remediation. That removes the un-converged material from this PR without hiding the risk, and puts the detailed guidance where someone who owns the deployment model can get it right. Getting that page right is a follow-up, noted in the PR description. --- reference/cli/authentication.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index 882ed6b14..dd2a9dc1c 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -228,15 +228,9 @@ Do not build a runbook around a non-zero exit at day 31 — watch the operation' It is not sufficient for a **loopback** target. `authentication.authorizeLocal` defaults to `true` and grants superuser to any request from `127.0.0.1` or `::1`, so an expired token there produces a genuinely successful, fully privileged run — indistinguishable from a correctly authenticated one. No check on the exit code or the result can catch it, because there is nothing failing to observe. -The fix is configuration, not monitoring: set **`authentication.authorizeLocal: false`** on any node a runner can reach, and restart Harper — the flag is read once at startup, so a live config change does not take effect for jobs already running against that node. +**The fix is configuration, not monitoring**, and it belongs to the node rather than the CLI: local authorization has to be turned off on any Harper instance a CI runner can reach. `authentication.authorizeLocal: false` closes the loopback-TCP path, but it is not the whole story — the operations API domain socket is trusted by a separate rule the flag does not gate, and a same-host reverse proxy makes ordinary remote traffic arrive as loopback. Get the full picture, and the restart requirement, from [`authorizeLocal`](../security/configuration.md#authorizelocal) before relying on any of it. -Choosing a non-loopback target is _not_ sufficient on its own. A same-host reverse proxy makes every forwarded request arrive from `127.0.0.1`, so `https://prod:9925` fronted by nginx on the Harper node is still a loopback peer as far as authorization is concerned. See [`authorizeLocal`](../security/configuration.md#authorizelocal), which carries the same warning for local proxies generally. - -Turning it off does not break local `harper` commands: those go over the operations API domain socket, which is trusted by a separate rule that this flag does not gate. - -:::danger -That separate rule is also the limit of the remediation. A connection arriving on the operations socket is authorized as superuser with no credential, and `authorizeLocal: false` does not change that. So if a reverse proxy's upstream is the operations socket rather than a TCP port, setting the flag closes nothing — every proxied request is still superuser. Do not expose the operations API socket through a proxy, and treat filesystem permissions on it as the access control. -::: +The point for a pipeline author is narrower: **an expired token against a node with local authorization enabled produces a successful, fully privileged run.** No exit code and no result check can detect it. If you cannot confirm how local authorization is configured on the node you deploy to, do not rely on token expiry surfacing as a failure. ::: A `403`, and any other refresh failure — a 5xx, a timeout, a connection error — does not stop the command, and what happens next depends on which credentials you supplied: From 59691d4a4f093f1f4623607e30f08ba704e28592 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 14:53:04 -0400 Subject: [PATCH 17/18] docs(security): give authorizeLocal the facts the CLI page defers to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit scoped the loopback remediation down to a pointer at `reference/security/configuration.md#authorizelocal`, promising "the full picture, and the restart requirement" there. That page did not have either — a two-sentence option description with a local-proxy aside. So the deferral was a dangling promise, which is worse than either keeping the detail on the CLI page or not promising it. Fixed at the destination rather than by rewording the pointer again. The option now carries the three facts an operator needs to act on it, all verified in `security/auth.ts`: - It is read once at startup, so a change needs a restart. - A same-host reverse proxy makes remote traffic arrive from `127.0.0.1`, so a non-loopback target URL does not avoid the exposure. - It does not gate the operations API domain socket, which is why local `harper` commands work without credentials — and why disabling the option does not protect a proxy whose upstream is the socket. Cross-linked back to the CI section, since the pipeline case is what makes this concrete. This breaks the "no further content edits" line I set one commit ago. The reason: the finding was that my own deferral pointed at content that did not exist, and shipping a broken security cross-reference to file a follow-up later is the wrong trade. It is also a different file — the CLI page's wording is unchanged, so this is not another iteration of the loop I was trying to leave. --- reference/security/configuration.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/reference/security/configuration.md b/reference/security/configuration.md index c34ad1d6c..60a918f82 100644 --- a/reference/security/configuration.md +++ b/reference/security/configuration.md @@ -23,7 +23,15 @@ authentication: _Type: boolean — Default: `true`_ -Automatically authorizes requests from the loopback IP address (`127.0.0.1`) as the superuser, without requiring credentials. Disable this for any Harper server that may be accessed by untrusted users from the same instance — for example, when using a local proxy or for general server hardening. +Automatically authorizes requests from the loopback IP address (`127.0.0.1` or `::1`) as the superuser, without requiring credentials. Disable this for any Harper server that may be accessed by untrusted users from the same instance — for example, when using a local proxy or for general server hardening. + +Three things to know before relying on either setting: + +- **It is read once at startup.** Changing it does not affect a running instance; restart Harper for the new value to take effect. +- **A same-host reverse proxy makes remote traffic look local.** If a proxy on the Harper node forwards to Harper, requests arrive from `127.0.0.1` however remote the original client was, so leaving this enabled authorizes those clients as superuser. Choosing a non-loopback target URL does not avoid this. +- **It does not gate the operations API domain socket.** A connection arriving on that socket is authorized as superuser regardless of this setting, which is what lets local `harper` CLI commands work without credentials. Disabling this option therefore does not protect a proxy whose upstream is the socket — do not expose the socket through a proxy, and treat filesystem permissions on it as the access control. + +For why this matters to a CI pipeline whose token has expired, see [Token credentials for CI/CD](../cli/authentication.md#token-credentials-for-cicd). ### `cacheTTL` From 1a8dde1a843b830e89bd8ff797e6499998a4f52b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Mon, 24 Aug 2026 15:40:30 -0400 Subject: [PATCH 18/18] docs(cli): local authorization applies only when no credential is attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings from @kriszyp's review, and the second supplies a hinge the page was missing rather than just a correction. **The loopback guidance and the expired-operation-token bullet did contradict each other**, exactly as flagged. The resolution is in the control flow: `security/auth.ts` is an `else if` chain, so the local-authorization branch is reached only when a request arrives with no `Authorization` header. A request carrying a token — even an expired one — is validated in the first branch and rejected on its merits, loopback or not. That makes the whole section coherent for the first time: the dangerous shape is precisely refresh-token-only, because a failed refresh leaves no header to validate; an expired operation token is comparatively safe because it still produces a header. Stated explicitly, and the one-style tip's caveat is reconciled with it — it previously said no credential style protects you on loopback, which overstates now that the distinction is "is a credential attached" rather than "which style". **`reference/cli/overview.md` was left inconsistent.** It still pointed CI users at `HARPER_CLI_USERNAME`/`HARPER_CLI_PASSWORD`, omitted both token variables, and claimed its abbreviated list resolved "in the order shown". Token credentials added with the `--for-ci` pointer, CI/CD recommendation moved to them, and the ordering claim replaced with an explicit note that the list is abbreviated and not in precedence order. This is a symmetry miss on my part — I updated authentication.md and commands.md and never grepped for other pages making precedence claims. --- reference/cli/authentication.md | 6 ++++-- reference/cli/overview.md | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index dd2a9dc1c..f139f527a 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -55,7 +55,7 @@ For remote Operations API commands, the CLI uses the first complete authenticati | A one-off admin command | `auth_username=` / `auth_password=` | | Local development | `harper login` | -Setting a token _and_ leaving `username=`/`password=` on the command is the combination that turns a token failure into an identity change rather than an error. One caveat: this rule bounds _which_ credential is used, not whether one is required at all. Against a loopback target Harper authenticates by address, so no choice of style protects you there — see the refresh-behavior note below. +Setting a token _and_ leaving `username=`/`password=` on the command is the combination that turns a token failure into an identity change rather than an error. One caveat: this rule bounds _which_ credential is used, not what happens when none resolves. A loopback node authorizes a request that arrives with no credential at all as superuser, so the style matters less there than whether a credential is attached — see the refresh-behavior note below. ::: Credentials are resolved as a pair and are never combined across sources. An incomplete pair supplied with dedicated authentication parameters or in the target URL causes the command to fail. An incomplete environment-variable pair is skipped with a warning so that a saved login token can still be used. @@ -237,7 +237,9 @@ A `403`, and any other refresh failure — a 5xx, a timeout, a connection error - **Refresh token only** (what `--for-ci` provisions): no bearer token is attached. Against a remote target the command fails as unauthenticated — unless it also carries `username=` and `password=` operation parameters, in which case it authenticates as that pair instead, a different identity than the one you configured. - **Refresh token only, against a loopback target**: it does not fail at all. `authentication.authorizeLocal` defaults to `true`, so a request from `127.0.0.1` or `::1` is authenticated as **superuser** with no credential checked. On a self-hosted runner pointed at its own node, an expired token therefore produces a green, fully privileged run rather than an error. This is the failure mode least likely to be noticed. -- **An expired operation token as well**: that expired token is still attached, so the request goes out carrying it and the server rejects it with `403` — token expiry answers `403` on the operations path too, since both token types are checked by the same validator. You get a rejection rather than a silent identity switch, but do not alarm on `401` for it. +- **An expired operation token as well**: that token is still attached, so the request carries an `Authorization` header and the server validates it — rejecting it with `403`, since expiry answers `403` on the operations path too. You get a rejection rather than a silent identity switch, but do not alarm on `401` for it. + +The difference between those two outcomes is whether an `Authorization` header is sent at all, and it is the hinge for the loopback case above: local authorization is only consulted when a request arrives with **no** credential. A request carrying a token — even an expired one — is validated and rejected on its merits, loopback or not. A request carrying nothing is what a loopback node authorizes as superuser. So the dangerous shape is precisely the refresh-token-only one, where a failed refresh leaves no header to validate. A `200` response that contains no `operation_token` is not reported at all. A refresh failure therefore does not reliably halt a pipeline, and a zero exit is not proof that the identity you configured is the one that ran. diff --git a/reference/cli/overview.md b/reference/cli/overview.md index dbf522179..cc254bd3b 100644 --- a/reference/cli/overview.md +++ b/reference/cli/overview.md @@ -161,11 +161,12 @@ Provide credentials via: - **Dedicated authentication parameters**: Use `auth_username= auth_password=` for one-off commands - **Target URL credentials**: Embed a complete username and password in the URL (supported, but not recommended because URLs are easily exposed) -- **Environment variables and `.env` files**: Use `HARPER_CLI_TARGET`, `HARPER_CLI_USERNAME`, and `HARPER_CLI_PASSWORD` (recommended for CI/CD and project-specific configuration) +- **Environment variables and `.env` files**: Use `HARPER_CLI_TARGET` with `HARPER_CLI_USERNAME` and `HARPER_CLI_PASSWORD` (project-specific configuration) +- **Token credentials**: `HARPER_CLI_REFRESH_TOKEN`, provisioned with [`harper login --for-ci`](./authentication.md#token-credentials-for-cicd) (recommended for CI/CD) - **Persistent Login**: `harper login` to store tokens (recommended for local development) - **Legacy parameters**: `username= password=` remain a fallback when no higher-priority authentication source is available -Starting in v5.2.0, these sources are resolved in the order shown. See [CLI Authentication](./authentication.md#authentication-precedence) for the complete order, including the preferred and legacy environment-variable namespaces. +This list is abbreviated and is **not** in precedence order. See [Authentication Precedence](./authentication.md#authentication-precedence) for the resolution order, which changed in v5.2.0, and for the preferred and legacy environment-variable namespaces. Configure one style per context rather than combining them. **Example: Persistent Login and `.env`**: