Skip to content

feat: support 2026-09-01 gateway API - #378

Open
alexbouchardd wants to merge 28 commits into
mainfrom
feat/api-2026-09-01
Open

feat: support 2026-09-01 gateway API#378
alexbouchardd wants to merge 28 commits into
mainfrom
feat/api-2026-09-01

Conversation

@alexbouchardd

Copy link
Copy Markdown
Contributor

Summary

  • update the CLI and cached OpenAPI source to API version 2026-09-01
  • replace project mode/team_mode handling with product/team_product, while retaining local config compatibility
  • send destination rate limits through config.delivery_policy and add delivery-group flags for standalone and inline destinations
  • expose delivery-group filters for events, request events, metrics, and MCP tools
  • update generated reference docs, examples, fixtures, and API-version expectations

Testing

  • env -u GOMODCACHE go test ./...
  • env -u GOMODCACHE go run ./tools/generate-reference --check
  • self-contained guest and login acceptance tests

Credentialed destination and connection acceptance tests were not run because HOOKDECK_CLI_TESTING_API_KEY is not configured in this environment.

@leggetter

leggetter commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Claude-assisted review - I ran /code-review over the diff, did a manual pass, then checked the findings against the live API and the published 2026-09-01 OpenAPI spec. Flagging the provenance up front: the verification below is real, the prose is generated.

Checklist so we can track what gets picked up. Tick as they land, or push back on any of them.

Edited after posting: the /projects finding is withdrawn once I checked core origin/staging. Details in the struck-through item below.

Blocking

  • /projects doesn't exist on prod. Withdrawn - this is a deploy-order dependency, not a CLI bug. origin/staging in core registers the list route on both paths:

    const path = '/projects';
    // The published CLI lists projects through GET /teams; that alias is kept
    // (and filtered out of the OpenAPI docs) until CLI versions move to /projects.
    const cli_legacy_list_paths = [path, '/teams'];

    So /projects is real and intended, it just isn't on prod yet - running this branch against prod today gives 404 Cannot GET /2026-09-01/projects, while /teams answers on both API versions. The one thing to settle is ordering: this PR can't ship before that core release. Since the /teams alias is deliberately kept for published CLIs, staying on /teams for now would also decouple the two releases, if that's preferable.

    Checked the scope declaration too (scope: 'projects.read') - withScopes passes CLI-authenticated callers through untouched, so that won't bite.

  • --delivery-group will 400 on four of the seven metrics endpoints. It's added in addMetricsCommonFlagsEx (pkg/cmd/metrics.go:86), so every metrics subcommand gets it, but per the spec only /metrics/attempts, /metrics/events and /metrics/queue-depth accept delivery_group. /metrics/requests, /metrics/transformations, /metrics/events-by-issue and /metrics/events-pending-timeseries don't, and their filters are additionalProperties: false, so it's a 400 rather than an ignored field. metrics events --measures pending routes to events-pending-timeseries, so that one breaks too. The skipIssueID mechanism in that file already solves this shape. Same gap in pkg/gateway/mcp/tool_metrics.go:58.

    Re-checked this against core origin/staging rather than the published spec, in case the spec was just stale: resolving each filter schema gives delivery_group on event_filters_schema, attempt_filters_schema and queue_depth_filters_schema only. request_filters_schema, transformation_filters_schema, events_pending_timeseries_filters_schema and events_by_issue_filters_schema don't carry it. So this one survives the deploy.

Worth fixing

  • ListProjects drops the unmarshal error (pkg/hookdeck/projects.go:20). postprocessJsonResponse(res, &projects) returns an error that isn't checked, so a 200 with an unexpected shape gives an empty slice and a nil error - zero projects listed, no failure. checkAndPrintError catches the non-2xx case, so this only bites on a shape mismatch, which is exactly what an endpoint rename risks.

  • No team_mode fallback on ValidateAPIKeyResponse / PollAPIKeyResponse / CIClient. If team_product is ever absent, ProjectProduct, ProjectMode and ProjectType all become "", IsGatewayProject("") is false, and every hookdeck gateway ... command fails with this command requires a Gateway project; current project type is (blank). To be fair to the change: I tested this against prod with project_type, project_mode and project_product stripped from my config, and whoami still printed Project type: Gateway - so the live API does return team_product and this isn't currently broken. Keeping the old field as a fallback through ModeToProduct is cheap insurance rather than a fix.

  • delivery_group is missing ,omitempty (pkg/hookdeck/events.go:18), unlike ResponseStatus two lines below. event list --output json now emits "delivery_group": null on every event, which changes output for anything diffing or schema-checking that JSON.

  • REFERENCE.md:1937 lists the metrics common flags by hand and doesn't mention --delivery-group. generate-reference --check passes because it emits no flag table for those subcommands.

  • gofmt regression. pkg/config/profile.go and pkg/config/profile_credentials_test.go are unformatted on the branch - the new ProjectProduct field breaks the struct alignment. profile.go was clean on main. Nothing in CI catches formatting, which is why it got through.

  • Five copies of the same fallback ladder. Type -> Product -> legacy Mode is open-coded at pkg/cmd/gateway.go:50, pkg/cmd/whoami.go:72, Profile.SaveProfile, Config.setProfileFieldsInViper and Config.constructConfig. One Profile.ResolveProjectType() would cover all five and stop the next caller getting the precedence subtly wrong.

  • Case handling splits down the middle of pkg/config/project_type.go. ProductToProjectType, ProductToLegacyMode and ModeToProduct all lowercase their input; ProjectTypeToProduct and IsGatewayProject are case-sensitive. Sibling functions, opposite contracts, nothing documenting which is which.

Tests

  • The upgrade path has no test. pkg/config/config_test.go isn't touched, but constructConfig gained the back-fill that every existing user hits on first run after upgrading (ProjectProduct derived from a legacy project_mode). Nothing asserts project_mode = "inbound" yields event_gateway, and the existing "use project" test still passes only a legacy mode and never checks project_product was written.

  • TestProductMappings covers about a third of the matrix. Seven flat assertions in a file where every neighbour is table-driven. Missing: ProductToProjectType("") (the wipe case above), ProjectTypeToProduct for Console/Outpost/unknown, ProductToLegacyMode for console/outpost/empty, ModeToProduct for inbound/console/outpost/unknown, anything case-related, and a Type -> Product -> Type round trip that would pin the deliberate outbound-to-inbound flattening currently described only in a comment.

  • No acceptance test asserts project_product reaches disk. product shows up in the ATs only inside three mock responses. Nothing checks the field is persisted to config.toml after login or project use, which is the one end-to-end guarantee the rename needs.

Not blocking

  • 99 hardcoded 2026-09-01 literals across test files, 87 of them in pkg/gateway/mcp/server_test.go. APIPathPrefix says "Change in one place when the API version is updated" and 23 test sites already use it. Mechanical, but the next bump repeats all of this churn.

  • buildDeliveryPolicy (pkg/cmd/destination_common.go:81) runs regardless of destination type, and DestinationTypeConfigCLI in the spec has no delivery_policy and is additionalProperties: false. So --destination-type CLI plus the new delivery-group flags gives an opaque API 400 instead of a client-side error. Same shape as the pre-existing rate_limit behaviour, so this widens an existing surface rather than adding a new break.

Open question on naming

Not blocking, and I may be missing context from the API side. product reads odd to me next to a display type of Gateway / Console / Outpost, and the CLI now carries three vocabularies at once: product on the wire, legacy mode in config, and type in output. The public spec documents no project resource at all, so product is internal naming rather than something the public API commits to. Is product the settled term API-side, or is this the moment to align on one word? --output json still emits type, so nothing user-facing changes either way today.


Verified while reviewing: go build ./..., go test ./... and generate-reference --check all pass on the branch, and the delivery-policy payload the CLI builds (rate, period, groups.{key,rate,rate_period,overrides}) matches DestinationDeliveryPolicy in the spec exactly, including the second|minute|hour group-period enum.

I can put the non-blocking fixes and the test gaps into a PR against this branch if that's easier than folding them in yourself.

Findings from reviewing #378. The `/projects` question is not addressed here -
that is a deploy-ordering decision, not a code change.

Only offer --delivery-group where the API accepts it. The flag was added to
every metrics subcommand, but delivery_group exists only on the events,
attempts and queue-depth filter schemas, and those filters are
additionalProperties:false. So `metrics requests --delivery-group` and
`metrics transformations --delivery-group` were a guaranteed 422, as were the
two `metrics events` routes that land on events-pending-timeseries or
events-by-issue. The flag is now omitted where it cannot work and rejected
client-side on the two routes that share a command with routes where it can.
metricsFlagOpts replaces the positional skipIssueID bool so a second exclusion
does not turn every call site into unreadable booleans.

Fall back to team_mode when team_product is absent. The cutover left no
fallback: an empty product blanks ProjectProduct, ProjectMode and ProjectType
at once, IsGatewayProject("") is false, and every gateway command then fails
with an empty project type in the message. The live API does return
team_product today - verified against prod with the type, mode and product
stripped from a config - so this is insurance, not a repair.

Stop ListProjects swallowing a shape mismatch. The unmarshal error was
discarded, so a renamed field or a wrapped envelope would return an empty list
and a nil error: "you have no projects" rather than a failure. Exactly the risk
an endpoint rename introduces.

Collapse five copies of the Type -> Product -> Mode ladder into
Profile.ResolveProjectType. Same precedence, one place to get it wrong.

Make the two case-sensitive mappers case-insensitive like their three
siblings in the same file. Nothing documented which was which.

Add ,omitempty to Event.DeliveryGroup so `event list --output json` does not
start emitting "delivery_group": null on every event.

Document --delivery-group in REFERENCE.md, including where it does not apply.

Run gofmt on the files this change touches; profile.go and
profile_credentials_test.go were unformatted on the branch.

Tests. The upgrade path had none: a config written before this release has no
project_product, and nothing asserted it gets derived from the legacy mode -
which is what every existing user hits on first run. The mapping tests covered
roughly a third of the matrix; they are now table-driven per function and
include the empty-product case, case-insensitivity, and a round trip that pins
the deliberate outbound-to-inbound flattening. The acceptance test now asserts
project_product actually reaches config.toml, which nothing did before: every
other test on this path reads a mock this repo also writes.

Refs #378

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
@leggetter

Copy link
Copy Markdown
Collaborator

Follow-up PR with the fixes from the review above: #379 (targets this branch, so merge / cherry-pick / close as you prefer).

Covers everything on the checklist except the /projects deploy-ordering question, which is yours to call. Ticking the items it addresses:

  • --delivery-group restricted to the endpoints that accept it, plus client-side errors on the two metrics events routes that can't use it
  • ListProjects unmarshal error propagated
  • team_mode fallback
  • ,omitempty on delivery_group
  • REFERENCE.md
  • gofmt on the two regressed files
  • five fallback ladders collapsed into Profile.ResolveProjectType()
  • case handling aligned
  • upgrade-path test, mapping matrix, acceptance assertion on project_product

Left alone: the 99 hardcoded version literals in tests, and the CLI-destination delivery-policy validation - both judgement calls that are yours rather than mine.

leggetter and others added 6 commits September 10, 2026 11:33
core `origin/staging` now names the field `type` on GET /projects and
`team_type` on the CLI auth endpoints, with the same event_gateway | console |
outpost values. The CLI followed the wire rename and the internal vocabulary
with it, so it speaks the same word as the API it calls.

That word was already taken. The CLI used ProjectType for the display label -
"Gateway", "Console", "Outpost" - so the two meanings had to be separated:

  ProjectType   event_gateway | console | outpost   what the API calls type
  TypeLabel()   Gateway | Console | Outpost         derived at print time

The label is presentation and is no longer stored. project_type on disk now
holds the API value, project_product is gone entirely (it only ever existed on
this unreleased branch, so nothing has written it), and project_mode is still
written for older CLIs reading the same file.

NormalizeProjectType is the single door every value goes through. It accepts an
API type, a display label written by an older CLI, or a legacy mode, and returns
the API type - so the three vocabularies converge in one place instead of at
each call site.

The auth structs read team_type, then team_product, then team_mode. Prod
currently serves team_product while staging serves team_type, so without that
chain a CLI shipping ahead of the deploy would blank the project type and fail
every gateway command. Verified against prod, which still serves the old field:
whoami resolves Gateway from a config stripped of all type information.

Two things deliberately unchanged, both user-facing: `--output json` still emits
gateway | outpost | console, and the `--type` filter still accepts them. The
API type is not used there - changing it would break anyone parsing that output.

Tests caught two regressions worth naming: the gateway error message printed the
wire value ("current project type is outpost") instead of the label the user was
shown, and saving local config dropped an unrecognized mode instead of carrying
it through. Both fixed.

Refs #378

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
…prod

The comments said the API rejects the unknown filter because the metrics
filter schemas are additionalProperties:false. It does not. Tested against
production with the pre-fix binary: `metrics requests --delivery-group` over a
14 day window returned count 87, identical to the same call with no filter,
while a bogus --source-id on that call returned 0. So the filter key is
recognized and applied when the endpoint supports it, and silently dropped
when it does not.

That makes withholding the flag more important than the original reasoning
suggested, not less. An opaque 422 at least tells the caller something is
wrong. Returning unfiltered totals under a flag that says they are filtered is
the silent-wrong-answer shape, and the caller has no way to notice.

No behaviour change: the flag was already withheld on requests and
transformations, and rejected client-side on the two `metrics events` routes
that cannot use it. Only the stated reason was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
@leggetter

Copy link
Copy Markdown
Collaborator

Prod testing update, now that /projects and the delivery-group work are deployed. Two things confirmed, one correction.

Confirmed working against prod

  • GET /2026-09-01/projects is live - project list and project use both work against it (they 404d a few days ago).
  • whoami resolves the type from team_type, so prod is on the new field name. The team_product fallback in auth.go / ci.go is now dead weight and can be dropped whenever we are sure nothing rolls back.
  • project use writes the new shape: project_type = 'event_gateway', project_mode = 'inbound', no project_product.
  • metrics events --delivery-group works; metrics requests --delivery-group is an unknown flag; metrics events --measures pending --delivery-group is rejected client-side with a readable message.

Correction to my earlier review

I said --delivery-group on the unsupported endpoints would be "a guaranteed 422" because those filter schemas are additionalProperties: false. That is wrong, and I should have tested it rather than reading it off the spec.

Ran the pre-fix binary against prod, 14 day window:

requests, no filter                      count: 87
requests + --delivery-group dg_bogus     count: 87   <- identical
requests + --source-id src_bogus         count: 0    <- a real filter key does filter

The API does not reject the unknown filter. It drops it and returns unfiltered totals. Debug logging confirms the CLI really did send filters[delivery_group]=dg_bogus.

This makes withholding the flag more important than I argued, not less. A 422 at least tells you something is wrong; unfiltered numbers under a flag that says they are filtered is the silent-wrong-answer shape, and there is no way for the caller to notice.

No behaviour change needed - the flag was already withheld in the right places. I have pushed 4f6bfd3 correcting the comments and the test rationale, which previously asserted the 422 story.

Gap worth knowing before release

Nothing in CI exercises /projects against the real API. project_use_test.go (which does run, in slice 0) contains only tests that make no API calls - its own header says so - and everything that needs "/projects endpoint access" sits in project_use_manual_test.go behind //go:build manual. Every other test on this path uses an httptest mock, so they cover our parsing, not the API contract or its auth.

Worth noting CI authenticates with HOOKDECK_CLI_TESTING_API_KEY, a project API key, whereas ListProjects goes through clientForCLIAuthValidate and in normal use carries a CLI key. So the CLI-key path against /projects is not covered by any automated test. I verified it by hand today; it works. Happy to add real coverage if we want it gated rather than manual.

leggetter and others added 16 commits September 14, 2026 10:32
The delivery-group fix was one instance of a general bug. metricsCommonFlags
added six filter flags to all four metrics subcommands, but each endpoint
declares its own filter schema and the API drops keys it does not recognize
instead of rejecting them. So the flags that did not apply returned unfiltered
totals under a flag saying they were filtered.

Measured against production, 14 day window:

  attempts, no filter                 count: 120
  attempts --source-id src_bogus      count: 120   <- source_id not in schema
  attempts --destination-id des_bogus count: 0     <- destination_id is

Nine flag/endpoint pairs were affected: requests offered destination-id,
connection-id and issue-id; attempts offered source-id, connection-id and
issue-id; transformations offered source-id, destination-id and status. All
silently no-ops.

Each subcommand now registers only the filters its endpoint honours, driven by
a metricsFilters set per endpoint. `metrics events` is the exception: it fans
out over four endpoints depending on measures and dimensions, so it offers the
union and validates per route at run time, naming the flag and saying why it
matters rather than just refusing it.

TestMetricsFlagsMatchTheEndpointSchemas asserts the whole matrix, so adding a
filter API-side fails the test rather than passing unnoticed.

Also: replace the hardcoded API version in tests with hookdeck.APIPathPrefix,
down from 99 occurrences to 4 - a genuine date value and three comments. The
constant's own comment says to change the version in one place; it was being
copied into 87 lines of pkg/gateway/mcp/server_test.go alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
TestMetricsAttemptsWithConnectionID ran `metrics attempts --connection-id
web_placeholder`, asserted the command succeeded and stopped there. It did
succeed - because the attempts endpoint has no webhook_id filter and the API
drops keys it does not recognize, so the call returned unfiltered totals under
a flag that said otherwise. The test was pinning the bug.

Measured against production over 14 days, with a control to rule out the
filter simply matching nothing:

  attempts, no filter                     count: 150
  attempts --connection-id web_bogus      count: 150   <- ignored
  events   --connection-id web_bogus      count: 0     <- honoured (145 unfiltered)

Same flag, same filters[webhook_id] mapping, different endpoints.

The test now asserts the flag is refused. It reads the message from stdout,
because that is where cobra writes flag errors - stderr is empty and the error
is only "exit status 1", so asserting on stderr would have passed regardless of
what the CLI printed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
ProjectMode was threaded from config through listen, proxy, the renderers and
the TUI, only ever to answer "is this a console project?". That is a project
type question, and mode is the vocabulary the API dropped.

Behaviour is identical: the only test anywhere was `== "console"`, and the
console type is the string "console" in both vocabularies. The other values
differ (inbound vs event_gateway) but neither is console, so every branch
resolves the same way. The comparison now names config.ProjectTypeConsole
rather than a bare string.

Renamed through links, listen, printer, proxy.Config, RendererConfig, both
renderers and the TUI model.

What is left called mode is deliberate and now documented on the field: the
pre-2026-09-01 config key, written so a CLI older than this one reading the
same config file still resolves a project, and the team_mode wire field read
as the last fallback in resolveType. Neither is vocabulary this codebase
should reason in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The config file is shared between CLI versions - a repo-local
.hookdeck/config.toml most obviously - and versions before 2026-09-01 only
understand the display label. Writing the API type there broke every
`hookdeck gateway ...` command for them:

  $ hookdeck gateway source list        # older CLI, config written by this one
  this command requires a Gateway project; current project type is event_gateway

Worse than one-off: both versions rewrite the file, so two people on different
versions would ping-pong it and the older one would break again each time.
Measured against production, not reasoned about.

So project_type on disk stays exactly what v2.5.0 writes - Gateway, Console,
Outpost - and project_mode keeps its legacy value. Internally nothing changes:
ProjectType still holds the API type, and NormalizeProjectType turns the label
back into it on read, which it already had to do for configs written before
this release.

Verified the full round trip with both binaries against production: new CLI
selects a project, old CLI runs gateway commands against the same file, old CLI
switches project, new CLI reads it back. Every step resolves correctly.

The label and the API type are one-to-one, so persisting the label loses
nothing. It also puts the two on-disk keys on the same footing: both are
compatibility surfaces speaking the vocabulary the most versions understand,
rather than one of them speaking the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Nothing exercised this endpoint for real. Every other test on the path uses an
httptest mock this repo also writes, so they verify our parsing and nothing
about the endpoint: not that it exists, not that it authorizes the credential
the CLI carries, not that its response still matches what we unmarshal. The
tests that do need it sit behind //go:build manual, and they are manual because
of the browser login flow, not because of the endpoint.

That mattered here. /teams moved to /projects in 2026-09-01 and the type field
was renamed twice during the release, and the mocks were updated alongside each
rename, so they would have passed whatever the API did.

On the credential: the acceptance runner bootstraps with a project API key, but
`hookdeck ci` exchanges it at /cli-auth/ci for a CLI client key, and that is
what lands in the config and goes on the wire. So this covers the CLI-key path
a real user has. ListProjects also drops the project scoping header via
clientForCLIAuthValidate, which is only observable against the real API.

Asserts the three things worth pinning, all verified against production first:
the endpoint answers a CLI key at all, `--output json` still reports gateway |
outpost | console rather than following the API's event_gateway rename, and the
--type filter speaks the same vocabulary as that output.

Runs in the existing project_use tag, which is already in CI slice 0. No new
secret needed - I had thought this needed one, which was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
My previous commit asserted the CI runner could list projects. CI proved
otherwise, with the clearest possible error:

  403 GET /2026-09-01/projects
  "This credential is scoped to a single project and cannot list all projects.
   Keys from hookdeck ci are project-scoped. Run hookdeck login for
   account-wide CLI access."

There are two kinds of CLI key. `hookdeck login` issues one bound to a user and
it can list projects; `hookdeck ci --api-key` issues a project-scoped key with
no user, and core rejects it:

  if (!req.context.user?.id) { throw new APICLIProjectScopedError() }

I missed this because my own key comes from `hookdeck login`, so every manual
check against production passed. The runner authenticates with `ci`, which is
also the real reason the project-listing tests were behind //go:build manual -
not the browser login flow, as their comment implies.

Not a regression: the guard landed for GET /teams in core 90e38ee395 and the
/projects rename inherited it. So `project list` has never worked with ci
credentials.

The CI-runnable test now asserts the restriction itself, which is more useful
than asserting the happy path anyway: it checks the 403 surfaces with the reason
and names the command that fixes it, so a change to that rule fails a test
instead of arriving as a support ticket.

The vocabulary assertions - that --output json and --type still speak
gateway | outpost | console rather than following the API's rename - move to the
manual suite, where an account-wide key exists to run them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The coverage I said was missing already existed, in the same file I edited.
TestProjectListFailsWithCIKeyAcceptance asserts the 403, and
TestProjectListShowsType / TestProjectListJSONOutput / TestProjectListFilterByType
assert the display labels and the gateway | outpost | console json vocabulary.
I missed them because I grepped for ListProjects and /projects, and their bodies
only say "project list".

Their skip message already states the rule I reported as a discovery:

  "CLI key required for listing projects; API and CI keys cannot list or
   switch projects"

So both halves were covered. Removed my duplicates in both the CI and manual
files.

What survives is one assertion the existing rejection test lacked: that the
error names `hookdeck login`. Checking the reason is not enough, because the
two kinds of CLI key are invisible to the user - without the command that fixes
it, the message says what went wrong and not what to do.

The real gap is not code: HOOKDECK_CLI_TESTING_CLI_KEY is not set in CI, so the
five tests gated on it skip on every run. They are written and correct; they
have never executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Five project list/use tests are gated on this variable and skip on every run
because it was never set, so they have been written and correct but never
executed. That is why the /teams to /projects move and two renames of the
project type field could all land without a test noticing.

It has to be an account-wide CLI key from `hookdeck login`. The key the runner
gets from `hookdeck ci` is project-scoped with no user attached, and core
returns 403 for that on this endpoint by design, so it cannot stand in.

The secret still needs adding to the repository; this only wires it through.
Until then the tests skip exactly as they do now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
…sting

Reverting c94662e. That commit passed HOOKDECK_CLI_TESTING_CLI_KEY to the
acceptance jobs so five long-skipping tests would run. They did run, and they
passed - but the key is account-wide, and an account-wide key reaches every org
its owner belongs to, including Hookdeck Prod.

This repository is public, so Actions logs are world-readable. Nothing leaked in
the run that happened: I checked the log for org and project names and found
none, because assertions only print on failure. But the failure path was one red
build away from publishing the lot:

  RunExpectSuccess -> require.NoError(t, err, "...stdout: %s...", stdout)
  TestProjectListShowsType -> assert.Contains(t, stdout, "|")

and `project list` on such a key returns every project in every org the owner
can see. We have already had two unrelated 502 flakes in this slice today, so
"only on failure" is not much of a guard.

So the variable goes back out of the workflow, with a comment saying what has to
be true before it returns: the key should belong to a test-only account in its
own org, so that what a failure can disclose is worth nothing.

Independently of that, the tests no longer put a listing in any failure message.
All nine call sites move from RunExpectSuccess, which formats stdout into its
error, to Run plus an assertion that carries no payload. Worth having whatever
key is used later - the listing is the credential's reach made legible, and it
does not belong in a public log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Re-applies what 8638838 reverted. The key behind the secret is no longer a
person's: it belongs to a test-only account, so the project inventory a failing
test could disclose is worth nothing, and it no longer reaches Hookdeck Prod.

Five tests have been skipping since they were written because this variable was
never passed through - which is how the /teams to /projects move and two renames
of the project type field all landed with nothing checking them end to end.

The assertion hardening from 8638838 stays: all nine project list call sites use
Run rather than RunExpectSuccess, so no failure message carries a listing. The
scoped account is the control that matters, but there is no reason to print the
thing either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
`hookdeck login --api-key <rejected>` announced "Starting browser sign-in...",
walked straight past the Enter prompt because there is nothing to read from,
and then polled for a confirmation nobody could give. CI spent 248 seconds on a
mistyped key before giving up; reproduced locally with stdin=/dev/null.

This is the shape #337 fixed in v2.5.0 - commands hanging instead of failing
where there is no terminal - and the same guard already exists a few lines below
for a key that is valid but project-scoped. The rejected-key branch simply
never got it.

The error also says what to check, because "invalid or expired" is often untrue
here. A project API key is a valid key that the CLI auth endpoints do not
accept; an organization API key is not accepted by any of them. Both arrive at
this branch, and telling their owner to re-authenticate a working key helps
nobody:

  the API key was rejected, and browser sign-in needs an interactive terminal;
  check the key is a CLI key from hookdeck login rather than a project or
  organization API key, or use hookdeck ci --api-key with a project API key

TestLogin_unauthorizedValidateStartsBrowserFlow had to start stubbing
stdinIsTerminal to true. It passed before without doing so, which is the bug
stated as a test: the browser flow ran whether or not anyone could complete it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
TestCIFailsFastWithInvalidAPIKeyAcceptance sends a deliberately invalid key and
asserts the CLI answers with a friendly authentication failure. When
POST /cli-auth/ci returns 502 instead, the assertion fails and the board goes
red for something the CLI did not do. That happened three times today.

A gateway error is not an authentication outcome, so the test cannot read it
either way. It now retries on 502 the way CLIRunner.Run already does, and skips
if every attempt is a 502 rather than reporting a product regression it has no
evidence for.

This test builds its own exec.Cmd rather than going through CLIRunner, which is
why it inherited none of the existing retry behaviour. The context deadline goes
from 60s to 150s so it actually covers the retries - sized for one attempt, it
would cut them short and reintroduce the flake.

Also drops a throwaway exec.Cmd that was only being used to carry args, dir and
env into the loop.

Refs #382

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The README distinguished a CLI key from a project API key and then said "within
the CLI both are stored and used the same way", which is not true of the thing
people hit: only a CLI key can list or switch projects. Nothing mentioned
organization API keys at all.

Verified against production rather than read off the code:

  /cli-auth/validate  CLI keys only - both API key types get 401
  /cli-auth/ci        project API key 200, organization API key 401
  /projects           project key -> 1 project; org key -> its organization's
                      projects given projects.read; CLI key -> every org the
                      user belongs to

So an organization API key cannot authenticate the CLI by any route, and
`hookdeck ci --api-key` needs a project key specifically. Neither was written
down anywhere, and the error you get is "your API key is invalid or expired" on
a key that is neither.

Refs #376

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
The catch-all 401 handler replaced whatever the API said with "your API key is
invalid or expired". That is an inference, and often a wrong one: a project API
key is valid but not accepted by the CLI auth endpoints, and an organization API
key is not accepted by any of them. Both land here, and both owners get told to
re-authenticate a working key.

We cannot do better by inspecting the key - validators.APIKey checks length and
nothing else, so a bare 401 cannot tell an expired CLI key from an API key from
a typo. So rather than guess more precisely, stop overriding the one source that
does know. When the response carries an explanation, lead with it; otherwise
fall back to the existing guidance.

Worth being honest about the immediate effect: /cli-auth/validate and
/cli-auth/ci currently answer with a bare "Unauthorized" body, which says
nothing the status code did not, so most users will still see the fallback. The
helper filters that case out deliberately rather than printing "Authentication
failed: Unauthorized". What changes is that a message the server does send now
reaches the user instead of being discarded - including from any endpoint that
already returns one, and from these two if core ever improves them.

Refs #283

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
b04a99f fixed this for `hookdeck gateway metrics` and touched pkg/gateway/mcp
only to substitute a constant in its tests. The MCP tool kept building params
from every argument and handing them to whichever endpoint the action routed
to, so `hookdeck_metrics` still offered 22 argument/endpoint pairs the API
silently drops - returning unfiltered totals to an agent with no way to notice.
The release note would have been untrue for anyone using MCP.

The matrix now lives in pkg/hookdeck beside the client, because both callers
reach the same endpoints and a matrix only one of them consults is one the other
drifts from. pkg/cmd and pkg/gateway/mcp both consult it; filter names are
supplied per caller so a CLI user reads --source-id and an MCP client reads
source_id.

Three further places where MCP diverged from the CLI in the same routing
function, all fixed here:

- The pending timeseries route sent measures[]=pending. The API expects count
  there; "pending" only selects the route, which the CLI has always rewritten.
- The by-issue route sent a request with no filters[issue_id]. The endpoint
  filters on it, so the route is meaningless without one; the CLI rejects this.
- dimensions did not map connection_id to webhook_id, though the tool schema
  tells callers it does. True of the filter, not of the dimension.

TestMetricsEvents_ByIssueRoute asserted a call with dimensions issue_id and no
issue_id was a success, pinning the second of those. It now passes an issue_id,
and a sibling covers the rejection.

TestMetricsToolRejectsFiltersTheEndpointIgnores mirrors the pkg/cmd guard, so a
filter added API-side now fails on both sides rather than being fixed for one.

Refs #382

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Two places still told people the opposite of what the code now does.

REFERENCE.md listed --source-id, --destination-id, --connection-id and --status
under "Common flags (all metrics subcommands)". They are not common: each
endpoint accepts a different set, which is the whole point of the change. The
list now holds only the flags that really are shared, with a table for the rest.

The MCP tool schema described every filter with no indication of where it
applies, so a model reading the schema was invited to pass source_id to attempts
and find out by being refused. Each argument now names the actions that honour
it, and the tool description says why passing one elsewhere is an error rather
than a no-op.

The runtime guard is the safety net. The schema is the contract, and it was
still wrong.

Refs #382

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
leggetter and others added 3 commits September 14, 2026 13:49
team_product was the API's name for this field between team_mode and team_type.
The CLI read it as a middle step in resolveType because, when that code was
written, production served team_product while staging served team_type - a CLI
shipping into that window would otherwise have blanked the project type and
failed every gateway command.

The window is closed. Production now returns team_type and nothing else:
neither team_product nor team_mode appears in the response. And no released CLI
ever read team_product - it was added on this branch, so there is no installed
base to keep working.

So the chain is back to two: the current team_type, and team_mode for an API
still serving the pre-2026-09-01 shape.

Verified after removing it, against production, with every type value stripped
from the config so the answer can only come from the API: whoami still reports
"Project type: Gateway".
… a browser

This is the CI failure from 9ff211a, and it is the acceptance-level twin of the
unit test that commit already updated. I fixed one and missed the other.

TestLoginAfterValidate401StartsBrowserFlowAcceptance asserted that a stale key
drops into browser sign-in. It cannot: there is no terminal in CI, so nobody can
press Enter or finish the flow in a browser, and the CLI now refuses rather than
polling for a confirmation that will never arrive. The test was describing
behaviour that could not have worked where it was running.

Renamed to say what it checks, and it now asserts the refusal - including that
the poll endpoint is never touched.

The config-persistence assertions moved out to
TestCIWritesTheProjectTypeAcceptance, because the login flow now stops before
anything is written. They go through `ci`, which is the path that works without
a terminal, and they still cover what they were added for: that project_type
reaches config.toml as the label older CLIs understand, that the legacy mode is
written beside it, and that project_product is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
TestTelemetryGatewayIssueGetProxy failed in CI and passed on a re-run against
the same commit, and the log said only:

  Error: Received unexpected error: exit status 1

That is the CLI exiting non-zero and nothing about why. require.NoError was
given the error alone while the command's stdout and stderr were discarded, so
the only available response was to re-run it and hope.

Both streams and the issue id now go into the failure message. The id matters
because this test, unlike its two siblings, lists issues without a status
filter, so it can pick up a resolved or dismissed one where they would skip.

Deliberately not narrowing the filter to OPENED to match the siblings. If
`gateway issue get` is supposed to work for an issue in any state, narrowing it
would hide a real defect rather than fix a flake, and nothing currently
establishes which it is. The next failure will now say.

Refs #387

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

API-contract, cache-versioning, and delivery-policy validation issues can cause incorrect project detection or silently ignored configuration.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Migrates the CLI to the 2026-09-01 Gateway API and adds delivery-group support across CLI, metrics, MCP, configuration, and tests.

Changes:

  • Updates API versioning and project-type handling.
  • Moves rate limits into delivery policies and adds delivery-group options.
  • Adds delivery-group filters and endpoint-specific metrics validation.
File summaries
File Description
.github/workflows/acceptance.yml Adds account-wide CLI test credentials.
AGENTS.md Updates the OpenAPI reference.
README.md Documents credentials, delivery groups, and project types.
REFERENCE.md Regenerates CLI flag documentation.
test-scripts/test-api-upsert-behavior.sh Updates API paths.
test/acceptance/README.md Updates acceptance-test documentation.
test/acceptance/connection_test.go Verifies nested delivery policies.
test/acceptance/destination_config_json_test.go Tests delivery-policy JSON.
test/acceptance/guest_login_acceptance_test.go Updates auth response fixtures.
test/acceptance/helpers_retry_test.go Uses the shared API prefix.
test/acceptance/login_auth_acceptance_test.go Tests headless login and persisted type behavior.
test/acceptance/metrics_test.go Rejects unsupported attempt filters.
test/acceptance/project_use_test.go Updates project tests and output safety.
test/acceptance/telemetry_test.go Updates telemetry expectations and diagnostics.
pkg/cmd/actionable_error_test.go Tests unauthorized server messages.
pkg/cmd/connection_create.go Adds inline destination delivery groups.
pkg/cmd/connection_upsert.go Merges delivery policies during upsert.
pkg/cmd/connection_upsert_test.go Tests connection delivery policies.
pkg/cmd/destination_common.go Builds and validates delivery policies.
pkg/cmd/destination_config_json_test.go Tests delivery-policy construction.
pkg/cmd/destination_create.go Registers delivery-policy flags.
pkg/cmd/destination_update.go Adds delivery-policy updates.
pkg/cmd/destination_upsert.go Adds delivery-policy upserts.
pkg/cmd/event_list.go Adds delivery-group filtering.
pkg/cmd/gateway.go Uses normalized project types.
pkg/cmd/gateway_test.go Updates gateway type tests.
pkg/cmd/listen_cli_key_test.go Updates login fixtures.
pkg/cmd/login.go Preserves project identity in local config.
pkg/cmd/metrics.go Adds endpoint-specific metric filters.
pkg/cmd/metrics_attempts.go Restricts attempt filters.
pkg/cmd/metrics_events.go Adds delivery-group dimensions and route validation.
pkg/cmd/metrics_filters_test.go Tests metrics filter matrices.
pkg/cmd/metrics_requests.go Restricts request filters.
pkg/cmd/metrics_transformations.go Restricts transformation filters.
pkg/cmd/project_list.go Renders project-type labels.
pkg/cmd/project_use.go Persists normalized project types.
pkg/cmd/request_events.go Adds delivery-group filtering.
pkg/cmd/root.go Improves unauthorized errors.
pkg/cmd/sources/types.go Updates the OpenAPI source URL.
pkg/cmd/whoami.go Resolves and displays API project types.
pkg/cmd/whoami_test.go Updates project-resolution tests.
pkg/config/clear_active_profile_credentials_test.go Updates credential-clearing assertions.
pkg/config/config.go Normalizes and persists project identities.
pkg/config/config_test.go Tests project-type compatibility.
pkg/config/load_config_file_test.go Updates legacy-config expectations.
pkg/config/profile.go Adds project-type resolution and persistence.
pkg/config/profile_credentials.go Maps authentication responses to project types.
pkg/config/profile_credentials_test.go Tests current and legacy response fields.
pkg/config/project_type.go Defines project-type normalization.
pkg/config/project_type_test.go Tests labels, modes, and normalization.
pkg/gateway/mcp/project_display_test.go Updates project-list fixtures.
pkg/gateway/mcp/telemetry_test.go Uses current API paths.
pkg/gateway/mcp/tool_events.go Passes delivery-group filters.
pkg/gateway/mcp/tool_help.go Updates MCP help text.
pkg/gateway/mcp/tool_metrics.go Validates filters and maps dimensions.
pkg/gateway/mcp/tool_metrics_filters_test.go Tests MCP metrics filtering.
pkg/gateway/mcp/tool_projects_errors.go Updates project authentication guidance.
pkg/gateway/mcp/tool_requests.go Filters and paginates request events.
pkg/gateway/mcp/tools.go Exposes new MCP arguments.
pkg/hookdeck/auth.go Updates authentication response fields.
pkg/hookdeck/auth_test.go Tests project-type decoding.
pkg/hookdeck/ci.go Updates CI client project fields.
pkg/hookdeck/client.go Advances the API path prefix.
pkg/hookdeck/events.go Adds event delivery groups.
pkg/hookdeck/metrics.go Serializes delivery-group filters.
pkg/hookdeck/metrics_filters.go Defines supported metrics filters.
pkg/hookdeck/metrics_test.go Tests metrics query serialization.
pkg/hookdeck/projects.go Uses the projects endpoint and strict decoding.
pkg/hookdeck/projects_test.go Tests project listing behavior.
pkg/listen/links/links.go Uses project types for dashboard links.
pkg/listen/listen.go Passes project types into listeners.
pkg/listen/printer.go Builds links from project types.
pkg/listen/proxy/proxy.go Renames proxy project configuration.
pkg/listen/proxy/renderer.go Renames renderer project configuration.
pkg/listen/proxy/renderer_interactive.go Uses project types for interactive links.
pkg/listen/proxy/renderer_simple.go Uses project types for simple links.
pkg/listen/tui/links.go Builds TUI links from project types.
pkg/listen/tui/links_test.go Updates TUI link tests.
pkg/listen/tui/model.go Renames TUI project configuration.
pkg/login/claimed_cli_key.go Uses normalized console detection.
pkg/login/claimed_cli_key_test.go Updates claimed-key fixtures.
pkg/login/client_login.go Fails fast for rejected headless credentials.
pkg/login/client_login_test.go Tests terminal-aware login behavior.
pkg/login/interactive_login.go Uses normalized console detection.
pkg/project/credentials_test.go Updates credential fixtures.
pkg/project/normalize.go Normalizes API project types.
pkg/project/normalize_test.go Tests project normalization.
Review details

Suppressed comments (2)

pkg/cmd/destination_common.go:94

  • Using groupRate > 0 here means an explicit negative --delivery-group-rate by itself is treated as if no group flags were supplied and silently ignored. Use groupRate != 0 so the existing groupRate <= 0 validation rejects it.
	hasGroups := groupKey != "" || groupRate > 0 || groupRatePeriod != "" || overridesJSON != ""

README.md:228

  • This row conflates two distinct credentials: hookdeck ci --api-key consumes a dashboard Project API key but returns/stores a project-scoped CLI client key. Split these into separate rows so users do not infer that the Project API key itself is the credential used by subsequent CLI commands.
| **Project API key** | dashboard, or `hookdeck ci --api-key` | the one project it belongs to | no |
  • Files reviewed: 88/88 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +5 to +6
// Project types as the API names them: `type` on GET /projects, and `team_type`
// on the CLI auth endpoints. These are the values stored in config and passed
cmd.Flags().StringVar(&flags.DestinationDeliveryGroupOverrides, "destination-delivery-group-overrides", "", "JSON object of group-specific delivery rate overrides")
}

func (f *destinationConfigFlags) validateDeliveryPolicyFlags(flagPrefix string) error {
Comment thread pkg/cmd/destination_common.go Outdated
Comment on lines +83 to +85
if period != "" && rate <= 0 {
return nil, fmt.Errorf("--%srate-limit must be a positive integer when rate limiting is configured", flagPrefix)
}
Comment thread pkg/cmd/sources/types.go Outdated
var (
openapiURL = "https://api.hookdeck.com/2025-07-01/openapi"
openapiURL = "https://api.hookdeck.com/2026-09-01/openapi"
cacheFileName = "hookdeck_source_types.json"
Comment thread README.md Outdated
```

Both flags are global, so they work with any command. A **CLI key** is tied to your user account and can navigate across projects; a **project API key** is scoped to a single project. Within the CLI both are stored and used the same way (see [Credential Types](#security-config-files-and-source-control)).
Both flags are global, so they work with any command. Which key you hold decides what the CLI can do:
Comment thread README.md Outdated
api_key = "api_key_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
project_id = "tm_5JxTelcYxOJy"
project_mode = "inbound"
project_type = "event_gateway"
Two of my own changes were regressions. Both were covered by tests that passed.

The 401 handler printed raw HTTP boilerplate to every user with an expired key.
unauthorizedServerMessage suppressed a message of exactly "Unauthorized", but
checkAndPrintError only produces that from a JSON body; these endpoints answer
text/plain, so the message it actually stores is "unexpected http status code:
401, raw response body: Unauthorized" - which then got printed as the
explanation. Reproduced with the real binary: "Authentication failed: unexpected
http status code: 401, raw response body: Unauthorized" where the guidance used
to be. Now suppressed, and the test drives a real httptest 401 through the
client rather than hand-building an APIError the client never emits, which is
why the original test passed while the code was broken.

The non-TTY login guard removed a flow that works. waitForLoginSession prints
the URL and polls when isSSH(), never reading stdin, so a remote session with a
human in it completes fine without a terminal. Refusing on "no terminal" alone
took that out - and left a user with an expired key worse off than one with no
key, since an empty key skips the block entirely. It now refuses only when
there is also no SSH session, which still fixes the CI hang.

TestLoginAfterValidate401StartsBrowserFlowAcceptance sets SSH_CONNECTION to
exercise exactly that branch. I had rewritten it to assert the refusal, which
recorded the regression instead of catching it. Restored.

An unrecognized project type no longer blanks the profile. resolveType returning
"" meant both project_type and project_mode were written empty, so a project
type this CLI has not heard of produced "current project type is ." on every
gateway command and discarded the value another CLI version could have used.
Each field now keeps what the API sent and is derived only to fill a gap, which
is the position setProjectIdentity already took for command-line values.

From Copilot, in the delivery-policy code:

- A negative --rate-limit with no period passed both guards and was silently
  dropped, so an explicitly invalid value succeeded. Any negative rate is now
  configured-and-invalid. Same for --delivery-group-rate.
- Delivery-policy flags were validated even when --config or --config-file was
  given, so an incomplete flag combination could reject a command whose flags
  would have been ignored anyway.
- The source-types cache was not keyed by API version: after upgrading, source
  create validated against the previous spec's types for up to 24 hours. Both
  the URL and the cache filename now derive from APIPathPrefix so they cannot
  drift apart again - which is how this happened.
- README showed project_type values the CLI never writes, and leaned on the
  hidden root --api-key/--cli-key flags that AGENTS.md says not to document as
  user-facing. The credential table now describes the supported flows.

Refs #382, #283

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnrKWZQASV7bFJ4oGWwmo9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants