Implement lfx api command for raw authenticated API calls - #6
Conversation
Add flags for method, headers, request body (--input, --field, --raw-field, or piped stdin), gjson-based response filtering (--query/-q), and --hostname to override the resolved API base URL. Refactor the token refresh logic in newAuthTokenCommand into a shared resolveAccessToken helper (also returning the login's audience as the default API base URL), used by both `auth token` and `api`. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Cover coerceFieldValue, apiJoinURL, and apiRequestBody (--field/ --raw-field merging and type coercion, --input from a file, piped stdin auto-detection, and the mutual-exclusion/malformed-flag error paths), following the table-driven and cli.Command-based patterns already used in auth_test.go. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
The design rationale it points to stands on its own; no need to keep a pointer to the tickets that originally prompted it. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Point to the actual design reason (the LFX API has no PATCH endpoints, favoring PUT with ETag/If-Match concurrency control) instead of an internal reference to the command's planning notes. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
…n Stat() error handling - lfx api now switches its default --method from GET to POST when the caller passes --field/--raw-field/--input but no explicit --method, matching the conventional default gh api and curl both use. A body implicitly picked up from piped stdin (no explicit body flag) does not trigger this, since that alone isn't a clear signal of intent. - apiRequestBody now surfaces a stdin Stat() failure as an error instead of silently falling back to an empty body. - Reworked the README PUT example to use --input - with a redirected file instead of a single --field, and added an -H "If-Match: <ver>" header to illustrate concurrency control on updates. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Replace the generic <path> placeholder with a live, manually-verified example (lfx api '/my-grants?v=1&object_type=projects'), demonstrating query-string usage alongside the existing POST/PUT examples. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Implements authenticated raw API requests through lfx api.
Changes:
- Adds request methods, bodies, headers, response querying, and error handling.
- Shares access-token resolution with
lfx auth token. - Adds helper tests, documentation, and GJSON dependencies.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
README.md |
Documents API usage. |
internal/credstore/credstore.go |
Removes a stale ticket reference from a comment. |
internal/commands/auth.go |
Extracts shared token resolution. |
internal/commands/api.go |
Implements the API command. |
internal/commands/api_test.go |
Tests API helpers and body handling. |
go.mod |
Adds GJSON dependencies. |
go.sum |
Records dependency checksums. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Move --insecure-storage/--backend to the root lfx command (new exported commands.CredentialStoreFlags) so lfx api can resolve credentials the same way lfx auth subcommands do. Previously these flags were only declared on the auth command group, so lfx api rejected them outright. - Reject extra positional arguments to lfx api instead of silently ignoring everything after the first. - Avoid a spurious blank "Error: " line on non-2xx responses by returning the HTTP status through cli.Exit's message instead of writing directly to stderr. - Replace strconv.ParseFloat-based --field number coercion with a JSON-number-syntax regex plus json.Number, so NaN/Inf/hex floats stay strings and large integers don't silently lose precision. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
|
Addressed the Copilot review feedback in b970ee0:
The remaining test-coverage gap (no test exercises Assisted-by: github-copilot:claude-sonnet-5 |
- Write the response body verbatim via os.Stdout.Write instead of fmt.Println, which appended an extra newline byte not present in the original response, corrupting raw/binary output and files produced by redirecting lfx api's stdout. - Default Content-Type to application/json for POST/PUT requests with a body, unless the caller already set it via -H (or it was set by --field/--raw-field, which already produce JSON). LFX platform APIs are overwhelmingly JSON, so this avoids needing an explicit -H on the common --input/stdin body case. - Fix the README --input example, which sent a body with no Content-Type before this change. - Update AGENTS.md's stale "Current State" section, which still described lfx api as an unimplemented stub. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
|
Addressed a follow-up round of Copilot review feedback in 5be4fb3:
Assisted-by: github-copilot:claude-sonnet-5 |
- Reject non-HTTPS base URLs (--hostname or a non-HTTPS --audience) before sending the bearer token, since the Authorization header would otherwise be transmitted in cleartext. Loopback hosts (localhost, 127.0.0.1, ::1) are exempted for local debugging. - Stream the response body directly to stdout when --query isn't set, instead of always buffering the full body in memory first; buffering is now only used when GJSON filtering requires it. - Add unit test coverage for the new apiRequireHTTPS helper. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/commands/api.go:223
- Checking the parsed value does not distinguish an omitted flag from
--input=. In the latter case the command neither auto-promotes to POST nor reports the empty filename; it silently falls through to implicit stdin handling (or sends an empty GET). Usecmd.IsSet(apiInputFlagName)consistently when deciding whether--inputwas supplied, so the empty value reachesos.ReadFile("")and produces an error.
func apiHasExplicitBody(cmd *cli.Command) bool {
return cmd.String(apiInputFlagName) != "" ||
- Disable automatic redirect-following in the HTTP client used by runAPI: http.DefaultClient can preserve the Authorization header across a same-host redirect even when it downgrades from HTTPS to HTTP, which would bypass apiRequireHTTPS and leak the bearer token. 3xx responses are now reported like any other non-2xx status instead. - Reject an explicitly empty --hostname= rather than silently falling back to the login audience, so a script with an unset hostname variable fails loudly instead of hitting the default production API. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/commands/api.go:233
- An explicitly supplied empty
--input=is treated as though the flag were absent. This also bypasses the--input/field mutual-exclusion check and may consume implicit stdin, so an unset script variable can produce a different request instead of failing. UseIsSetto recognize the flag and reject an empty value before selecting the body source.
return cmd.String(apiInputFlagName) != "" ||
andrest50
left a comment
There was a problem hiding this comment.
Solid implementation — the security layering (HTTPS enforcement + redirect blocking together), the resolveAccessToken refactor, and the json.Number precision fix are all well-considered. Tests follow established patterns. Approving; inline comments below are a mix of one thing to verify before merging and a few low-effort follow-ups.
No HTTP-level test for runAPI itself — pure helpers are well covered, but auth injection, Content-Type defaulting, redirect blocking, and non-2xx exit aren't exercised by CI. An httptest.NewTLSServer-based test for the happy path + a 4xx case would close that gap (can land in a follow-up).
|
AI-assisted summary: Addressed the latest round of review feedback (3833f79):
|
Addresses @dealako's review question about the trust model for --hostname: it validates HTTPS but not that the host belongs to LFX, so it's an advanced escape hatch, not a safety net. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
|
AI-assisted: @dealako — good catch, addressed in 67af917: added a one-line note to |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
internal/commands/api.go:337
url.JoinPathtreats the second argument entirely as path data, so the documented/my-grants?v=1&object_type=projectspath becomes/my-grants%3Fv=1&object_type=projects; the request does not send those query parameters. Parse the argument, join only its escaped path, and then restore its query components (with a query-bearing test).
return url.JoinPath(base, path)
internal/commands/api.go:247
- An explicit empty
--input=is counted as a body byapiHasExplicitBody(and promotes the request to POST), but theseinput != ""checks then treat it as omitted. This can read piped stdin or permit combination with fields instead of rejecting the invalid filename; useIsSetconsistently and reject an empty value.
if input != "" && (len(fields) > 0 || len(rawFields) > 0) {
return nil, "", fmt.Errorf("--%s cannot be combined with --%s or --%s", apiInputFlagName, apiFieldFlagName, apiRawFieldFlagName)
}
if input != "" {
resolveAccessToken now also returns the login environment recorded at `lfx auth login --env=...`. runAPI uses it to reject --hostname (remote or loopback) unless the current login used --env=development, narrowing the blast radius of a leaked or misdirected prod/staging bearer token per @dealako's review question. This is a heuristic tied to how login is normally done (--env and --audience are set together), not a cryptographic guarantee about what the token itself is scoped to. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
|
AI-assisted: Update to my earlier reply — went further than a usage-text note. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
internal/commands/api.go:362
- URL schemes are case-insensitive, but this exact comparison rejects valid secure URLs such as
HTTPS://api.example.com. Use a case-insensitive comparison so the HTTPS guard enforces the protocol rather than its spelling.
if parsed.Scheme == "https" {
internal/commands/api.go:349
url.JoinPathtreats the entire second argument as a path element, so a documented call such as/my-grants?v=1&object_type=projectsbecomes/my-grants%3Fv=1&object_type=projects; the server receives no query parameters. Parse the supplied argument first, join only its path component, then copy itsRawQuery/ForceQueryonto the joined URL, and add the README example as a test case.
return url.JoinPath(base, path)
internal/commands/api.go:251
- An explicitly empty
--input=is silently treated as though the flag were absent, even thoughapiHasExplicitBodysees it and promotes the request to POST. In scripts, an unset filename variable can therefore send an empty body or consume inherited piped stdin unexpectedly. Trackcmd.IsSet("input"), reject an empty value, and use that same boolean for mutual exclusion and input handling.
input := cmd.String(apiInputFlagName)
- apiJoinURL now parses path as a URL reference and reattaches its RawQuery/Fragment after joining, since url.JoinPath treats its second argument as a bare pathname and would otherwise percent-encode a query string (e.g. "?v=1&object_type=projects") straight into the path, dropping the query entirely. - apiRequestBody now uses cmd.IsSet(apiInputFlagName) instead of a string-empty check, so an explicitly empty --input= is rejected (and correctly participates in the --input/--field mutual-exclusion check) instead of silently falling through to implicit stdin handling. Addresses two Copilot findings from the latest review pass. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
apiJoinURL's url.JoinPath-based version had accumulated three distinct correctness bugs across two review rounds (query-string loss, escaped- slash unescaping, and the underlying complexity risk of a third): the simple TrimRight/TrimLeft concatenation never had any of them, since it passes path through byte-for-byte instead of round-tripping it through url.Parse/url.JoinPath. Reverting to it, per @andrest50/@dealako's original review consensus that the plain version was acceptable (hardening nit, not a defect), especially now that --hostname is gated to development-environment logins rather than relying on URL sanitization as the only safeguard. Also fixed apiRequireHTTPS's scheme comparison to be case-insensitive (strings.EqualFold), so "HTTPS://..." is accepted like "https://...". Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
internal/commands/api.go:175
- The fallback cannot distinguish an absent
Content-Typefrom an explicit empty override.-H 'Content-Type:'leavesHeader.Getempty, so this branch immediately reinstatesapplication/json, despite the header-handling contract above saying that this value can override the default. Check whether the header key exists rather than checking its value.
if len(body) > 0 && req.Header.Get("Content-Type") == "" && (method == http.MethodPost || method == http.MethodPut) {
internal/commands/api.go:136
audienceis loaded from the mutable plaintextstate.jsonandloadDeviceStateForBackendvalidates only its environment/IdP metadata, not this value. Changing justAudienceto an attacker-controlled HTTPS URL now causes a valid cached or refreshed bearer token to be sent there; before this PR,credstore.DeviceState.Audiencewas documented as display-only. Bind the request destination to trusted credential/token data (for example, persist the audience with the protected credentials and compare it, or validate the token'saudclaim) before using the state value as a URL.
if baseURL == "" {
baseURL = audience
The restriction exists to keep a prod/staging token -- the ones with real authority -- from being redirected to a third-party host at all; --hostname's troubleshooting use case only comes up in development. Allowing it there is independently safe because OAuth2 resource servers validate a token's issuer, not just its audience, so even a development-issued token could never be honored by prod/staging. Previous wording conflated these two points. Assisted-by: github-copilot:claude-sonnet-5 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
internal/commands/api.go:270
- Reading stdin fully before constructing the request makes memory usage proportional to the upload size; the file branch below has the same issue via
os.ReadFile. A large--inputupload can therefore exhaust memory before any bytes are sent. Return a streaming reader (and close files after the request) for raw input, while retaining buffered JSON only for field-generated bodies.
if input == "-" {
data, err := io.ReadAll(os.Stdin)
internal/commands/api.go:380
- The loopback exemption ignores the scheme and compares the DNS hostname case-sensitively. This accepts unsupported non-HTTP URLs such as
ftp://localhost, while rejecting the valid loopback URLhttp://LOCALHOST. Require an HTTP scheme for the exemption and normalize the hostname before matching it.
switch parsed.Hostname() {
case "localhost", "127.0.0.1", "::1":
return nil
|
Hi @emsearcy 👋 — follow-up review of the six commits since my last round ( 👏 Nice work this round:
Revision tracking (prior round):
Issue count (all open items):
Reconciliation: the two new Copilot comments this cycle (query-string loss in the Zero new findings from my scan. Build and tests green, branch mergeable. The one remaining minor is tracked and accepted as out of scope for this PR. ✅ Approved |
dealako
left a comment
There was a problem hiding this comment.
Follow-up round: all four prior items addressed. The --hostname token-exposure concern is resolved by the development-environment gate (verified the env value is trustworthy end-to-end via loadDeviceStateForBackend), the url→endpoint rename lands, the repeated--H Set behavior is documented as intentional, and the url.JoinPath experiment was correctly reverted after it introduced query-string-loss and encoded-slash regressions. Only remaining item is runAPI end-to-end test coverage, tracked in #7 and out of scope here. No new findings; build and tests green; branch mergeable.
✅ Approved
Summary
Implements
lfx api <path>for making raw authenticated HTTP requests against LFX platform APIs (similar togh api), per the plan in LFXV2-2517.-X/--method: GET, POST, PUT, or DELETE (default GET; auto-promotes to POST when a body is explicitly supplied via--field/--raw-field/--inputand no--methodwas passed, matchinggh api/curlconvention). PATCH is intentionally excluded — the LFX API has no PATCH endpoints, favoring PUT with ETag/If-Match concurrency control.Authorization: Bearer <token>via a newresolveAccessTokenhelper, refactored out ofnewAuthTokenCommandsoauth tokenandapishare one code path (including audience resolution as the default API base URL).-H/--header(repeatable),--input <file>|-,-F/--field(gh-style type coercion) /--raw-field(string-only, mutually exclusive with--input), and automatic stdin body detection when piped with no explicit body flag.-q/--query <gjson-expr>to filter the response body before output (newgithub.com/tidwall/gjsondependency).--hostnameto override the resolved API base URL (advanced/debugging escape hatch), rejected unless HTTPS (or loopback) viaapiRequireHTTPS, and redirects are never auto-followed so a 3xx response can't bypass that check or silently leak the bearer token.HTTP <status>to stderr and exit 1.--insecure-storage/--backendcredential-store selectors were moved fromauthto the rootlfxcommand solfx apican share them too; verified they still work in any position in the command line (urfave/cli v3 resolves persistent flags across the whole chain), so no README changes were needed there.Once this lands, the parent epic LFXV2-2509 can be closed out — this was its last remaining open child story.
Testing
make check(fmt,vet,lint,revive) andmake testall pass.internal/commands/api_test.gocoveringcoerceFieldValue,apiJoinURL,apiHasExplicitBody, andapiRequestBody(field/raw-field merging,--inputfrom file, piped-stdin auto-detection, stdinStat()failure, and the mutual-exclusion/malformed-flag error paths). End-to-end coverage ofrunAPIitself (method promotion, headers, query filtering, non-2xx handling) is tracked separately in #7.lfx api '/my-grants?v=1&object_type=projects'returned{"grants":[]}with exit 0.Assisted-by: github-copilot:claude-sonnet-5
🤖 Generated with GitHub Copilot (via OpenCode)