1122d4f1
delegation: a begin nobody has signed yet survives the sweep
a73x 2026-09-02 09:42
Commit message
docs/mcp.md
| Old | New | ||
|---|---|---|---|
| @@ -66,8 +66,10 @@ before you delegate accept it too**. | |||
| 66 | 66 | ||
| 67 | The delegation lives in memory only. A control-plane restart drops it and you | 67 | The delegation lives in memory only. A control-plane restart drops it and you |
| 68 | delegate again — the public key is stable, so that is one `ssh-keygen` and one | 68 | delegate again — the public key is stable, so that is one `ssh-keygen` and one |
| 69 | `delegate_complete`. `GET /api/v1/delegations` reports the expiry; `DELETE` | 69 | `delegate_complete`. A `delegate_begin` you never complete is held for an hour |
| 70 | ends it now. | 70 | and then abandoned, so a certificate signed long after the fact is refused; call |
| 71 | `delegate_begin` again for the current key. `GET /api/v1/delegations` reports the | ||
| 72 | expiry; `DELETE` ends it now. | ||
| 71 | 73 | ||
| 72 | Delegation requires the tenant to have at least one registered SSH user CA | 74 | Delegation requires the tenant to have at least one registered SSH user CA |
| 73 | (`eitri ca upload`), because a certificate signed by a CA your guests do not | 75 | (`eitri ca upload`), because a certificate signed by a CA your guests do not |
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -1005,7 +1005,7 @@ | |||
| 1005 | "patToken": [] | 1005 | "patToken": [] |
| 1006 | } | 1006 | } |
| 1007 | ], | 1007 | ], |
| 1008 | "summary": "Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process." | 1008 | "summary": "Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process once delegated; a begin left unsigned for an hour is abandoned and the next call returns a new key." |
| 1009 | }, | 1009 | }, |
| 1010 | "put": { | 1010 | "put": { |
| 1011 | "requestBody": { | 1011 | "requestBody": { |
internal/mcpserver/server.go
| Old | New | ||
|---|---|---|---|
| @@ -90,8 +90,9 @@ func NewServer(t *Tools, opts Options) *mcp.Server { | |||
| 90 | "Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+ | 90 | "Ask for the public key your CA is to sign, so eitri can reach your VMs. eitri holds no signing key and "+ |
| 91 | "cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+ | 91 | "cannot sign this itself — that is the point. Show the human the public key and the command, and wait "+ |
| 92 | "for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+ | 92 | "for them to hand back the certificate; do not try to produce it yourself. The key stays the same "+ |
| 93 | "until the control plane restarts — a delegation is held in memory, so after a restart call this "+ | 93 | "until the control plane restarts, and a begin nobody signs is abandoned after an hour — a "+ |
| 94 | "tool again for the new key rather than reusing an old certificate.", | 94 | "delegation is held in memory, so call this tool again for the current key rather than reusing an "+ |
| 95 | "old certificate.", | ||
| 95 | func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) }) | 96 | func(ctx context.Context, _ DelegateBeginIn) (BeginResult, error) { return dg.Begin(ctx) }) |
| 96 | register(s, "delegate_complete", | 97 | register(s, "delegate_complete", |
| 97 | "Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+ | 98 | "Hand back the certificate your CA signed. Certificates are public material, so passing one as an "+ |
internal/server/api/routes.go
| Old | New | ||
|---|---|---|---|
| @@ -334,7 +334,7 @@ var routeTable = []Route{ | |||
| 334 | Kind: KindJSON, | 334 | Kind: KindJSON, |
| 335 | Response: (*types.DelegationChallenge)(nil), | 335 | Response: (*types.DelegationChallenge)(nil), |
| 336 | Success: http.StatusOK, | 336 | Success: http.StatusOK, |
| 337 | Doc: "Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process.", | 337 | Doc: "Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process once delegated; a begin left unsigned for an hour is abandoned and the next call returns a new key.", |
| 338 | handler: (*API).handleBeginDelegation, | 338 | handler: (*API).handleBeginDelegation, |
| 339 | }, | 339 | }, |
| 340 | { | 340 | { |
internal/server/delegation/delegation.go
| Old | New | ||
|---|---|---|---|
| @@ -33,6 +33,12 @@ import ( | |||
| 33 | "golang.org/x/crypto/ssh" | 33 | "golang.org/x/crypto/ssh" |
| 34 | ) | 34 | ) |
| 35 | 35 | ||
| 36 | // defaultPendingGrace is how long Sweep keeps a keypair whose certificate has | ||
| 37 | // not come back yet. An hour is far longer than a human takes to run one | ||
| 38 | // ssh-keygen and far shorter than a handful of abandoned keypairs is worth | ||
| 39 | // worrying about. | ||
| 40 | const defaultPendingGrace = time.Hour | ||
| 41 | |||
| 36 | // Keyring holds one ephemeral keypair per tenant and, once delegated, the | 42 | // Keyring holds one ephemeral keypair per tenant and, once delegated, the |
| 37 | // certificate that makes it usable. | 43 | // certificate that makes it usable. |
| 38 | type Keyring struct { | 44 | type Keyring struct { |
| @@ -45,12 +51,18 @@ type Keyring struct { | |||
| 45 | // principals against the user being logged in as — not against the tenant. | 51 | // principals against the user being logged in as — not against the tenant. |
| 46 | Principal string | 52 | Principal string |
| 47 | 53 | ||
| 48 | mu sync.Mutex | 54 | mu sync.Mutex |
| 49 | tenants map[string]*entry | 55 | // pendingGrace is how long a begun-but-unsigned entry survives Sweep. It |
| 56 | // is unexported and set from defaultPendingGrace in New: how long eitri | ||
| 57 | // waits for a certificate is this package's rule, and a caller that could | ||
| 58 | // pin its own would be setting a security bound from the outside. | ||
| 59 | pendingGrace time.Duration | ||
| 60 | tenants map[string]*entry | ||
| 50 | } | 61 | } |
| 51 | 62 | ||
| 52 | type entry struct { | 63 | type entry struct { |
| 53 | key ssh.Signer // ephemeral, generated once per tenant per process | 64 | key ssh.Signer // ephemeral, generated once per tenant per process |
| 65 | began time.Time // when the keypair was minted; what Sweep's grace runs from | ||
| 54 | cert *ssh.Certificate | 66 | cert *ssh.Certificate |
| 55 | certSigner ssh.Signer | 67 | certSigner ssh.Signer |
| 56 | } | 68 | } |
| @@ -73,18 +85,20 @@ func New(now func() time.Time, principal string) *Keyring { | |||
| 73 | if now == nil { | 85 | if now == nil { |
| 74 | now = time.Now | 86 | now = time.Now |
| 75 | } | 87 | } |
| 76 | return &Keyring{Now: now, Principal: principal, tenants: map[string]*entry{}} | 88 | return &Keyring{Now: now, Principal: principal, pendingGrace: defaultPendingGrace, tenants: map[string]*entry{}} |
| 77 | } | 89 | } |
| 78 | 90 | ||
| 79 | // Begin returns the public key this tenant is to sign, generating the keypair | 91 | // Begin returns the public key this tenant is to sign, generating the keypair |
| 80 | // on first call and returning the SAME key every time afterwards. | 92 | // on first call and returning the same key on every call afterwards for as long |
| 93 | // as the entry lives. | ||
| 81 | // | 94 | // |
| 82 | // Stable-per-process is deliberate. Re-delegating after an expiry is then one | 95 | // Stability is deliberate. Re-delegating after an expiry is then one ssh-keygen |
| 83 | // ssh-keygen and one Complete, with no round trip to re-fetch a key that has | 96 | // and one Complete, with no round trip to re-fetch a key that has not changed. |
| 84 | // not changed — but only within one process: a restart generates a new key, | 97 | // Two things end it, and Complete's wrong-key refusal names both: a restart, |
| 85 | // which is what Complete's wrong-key refusal names. The public key is not a | 98 | // which mints a new key because nothing is persisted, and a begin left unsigned |
| 86 | // secret in any sense that matters: it is useless without a certificate, and | 99 | // past Sweep's grace, which abandons a keypair no certificate ever came back |
| 87 | // eitri cannot make itself one. | 100 | // for. The public key is not a secret in any sense that matters: it is useless |
| 101 | // without a certificate, and eitri cannot make itself one. | ||
| 88 | func (k *Keyring) Begin(tenant string) (string, error) { | 102 | func (k *Keyring) Begin(tenant string) (string, error) { |
| 89 | k.mu.Lock() | 103 | k.mu.Lock() |
| 90 | defer k.mu.Unlock() | 104 | defer k.mu.Unlock() |
| @@ -127,12 +141,14 @@ func (k *Keyring) Complete(tenant, certLine string, trusted func(ssh.PublicKey) | |||
| 127 | "user certificate — sign without `-h`") | 141 | "user certificate — sign without `-h`") |
| 128 | } | 142 | } |
| 129 | if !bytes.Equal(cert.Key.Marshal(), e.key.PublicKey().Marshal()) { | 143 | if !bytes.Equal(cert.Key.Marshal(), e.key.PublicKey().Marshal()) { |
| 130 | // Name the key that IS current. The usual cause is a certificate signed | 144 | // Name the key that IS current. The cause is always a certificate |
| 131 | // over a key from before a restart, and without the fingerprint to | 145 | // signed over a key this keyring no longer holds, and the two ways |
| 132 | // compare against there is nothing in the refusal to act on. | 146 | // that happens need different fixes, so the refusal names both. Without |
| 147 | // the fingerprint to compare against there is nothing to act on either. | ||
| 133 | return Delegation{}, fmt.Errorf("that certificate is over %s, but this tenant's current delegation key is "+ | 148 | return Delegation{}, fmt.Errorf("that certificate is over %s, but this tenant's current delegation key is "+ |
| 134 | "%s — the key changes when the control plane restarts. Call delegate_begin again and sign the key it "+ | 149 | "%s — the key changes when the control plane restarts, and when a delegation is begun and left "+ |
| 135 | "returns", ssh.FingerprintSHA256(cert.Key), ssh.FingerprintSHA256(e.key.PublicKey())) | 150 | "unsigned for longer than %s. Call delegate_begin again and sign the key it returns", |
| 151 | ssh.FingerprintSHA256(cert.Key), ssh.FingerprintSHA256(e.key.PublicKey()), k.pendingGrace) | ||
| 136 | } | 152 | } |
| 137 | 153 | ||
| 138 | ok, err = trusted(cert.SignatureKey) | 154 | ok, err = trusted(cert.SignatureKey) |
| @@ -226,16 +242,29 @@ func (k *Keyring) Revoke(tenant string) { | |||
| 226 | } | 242 | } |
| 227 | } | 243 | } |
| 228 | 244 | ||
| 229 | // Sweep drops every entry that is not doing anything: no certificate, or an | 245 | // Sweep drops every entry that is not doing anything: an expired certificate, |
| 230 | // expired one. It bounds the keyring by ACTIVE tenants rather than by every | 246 | // or a begin that was never signed and has outlived pendingGrace. It bounds the |
| 231 | // tenant that ever called Begin. An entry with no certificate survives until | 247 | // keyring by tenants that are delegating or delegated rather than by every |
| 232 | // the next sweep — it is one keypair, around a hundred bytes — so a caller who | 248 | // tenant that ever called Begin. |
| 233 | // takes a few minutes between Begin and Complete is not raced. | 249 | // |
| 250 | // The grace is there because Complete waits on a human. The tenant reads the | ||
| 251 | // key out of Begin, signs it with a CA that may sit on a hardware token or | ||
| 252 | // behind someone else's approval, and posts the certificate back minutes later. | ||
| 253 | // Sweep runs on a timer that knows nothing about that, so without the grace a | ||
| 254 | // tick landing mid-signature rotates the key underneath the caller and Complete | ||
| 255 | // refuses a certificate that was correct when it was signed. Waiting the grace | ||
| 256 | // out costs one keypair, around a hundred bytes. | ||
| 234 | func (k *Keyring) Sweep() { | 257 | func (k *Keyring) Sweep() { |
| 235 | k.mu.Lock() | 258 | k.mu.Lock() |
| 236 | defer k.mu.Unlock() | 259 | defer k.mu.Unlock() |
| 237 | for tenant, e := range k.tenants { | 260 | for tenant, e := range k.tenants { |
| 238 | if e.cert == nil || k.expiredLocked(e) { | 261 | if e.cert == nil { |
| 262 | if !k.Now().Before(e.began.Add(k.pendingGrace)) { | ||
| 263 | delete(k.tenants, tenant) | ||
| 264 | } | ||
| 265 | continue | ||
| 266 | } | ||
| 267 | if k.expiredLocked(e) { | ||
| 239 | delete(k.tenants, tenant) | 268 | delete(k.tenants, tenant) |
| 240 | } | 269 | } |
| 241 | } | 270 | } |
| @@ -254,7 +283,7 @@ func (k *Keyring) entryLocked(tenant string) (*entry, error) { | |||
| 254 | if err != nil { | 283 | if err != nil { |
| 255 | return nil, fmt.Errorf("delegation signer: %w", err) | 284 | return nil, fmt.Errorf("delegation signer: %w", err) |
| 256 | } | 285 | } |
| 257 | e := &entry{key: signer} | 286 | e := &entry{key: signer, began: k.Now()} |
| 258 | k.tenants[tenant] = e | 287 | k.tenants[tenant] = e |
| 259 | return e, nil | 288 | return e, nil |
| 260 | } | 289 | } |
internal/server/delegation/delegation_doc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,72 @@ | |||
| 1 | package delegation | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "runtime" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // TestDocsMatchThePendingGrace keeps the prose from drifting from the constant. | ||
| 13 | // defaultPendingGrace is the only place the grace is decided, and three surfaces | ||
| 14 | // restate it in words for readers who never see the source: the delegation | ||
| 15 | // section of docs/mcp.md, the delegate_begin tool description, and the route | ||
| 16 | // summary that becomes the OpenAPI spec and the TypeScript types. Each says the | ||
| 17 | // number exactly once, so this reads the real files and fails if the constant | ||
| 18 | // moves without them, rather than letting the promise quietly lie in three | ||
| 19 | // places at once. | ||
| 20 | func TestDocsMatchThePendingGrace(t *testing.T) { | ||
| 21 | want := graceProse(t, defaultPendingGrace) | ||
| 22 | for _, rel := range []string{ | ||
| 23 | "docs/mcp.md", | ||
| 24 | "internal/mcpserver/server.go", | ||
| 25 | "internal/server/api/routes.go", | ||
| 26 | } { | ||
| 27 | if !strings.Contains(string(readRepoFile(t, rel)), want) { | ||
| 28 | t.Errorf("%s no longer tells a caller a begin is abandoned after %q — update the wording "+ | ||
| 29 | "there and in the other two sites when defaultPendingGrace changes", rel, want) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | // graceProse renders a grace the way the three surfaces say it in English. Only | ||
| 35 | // the durations the prose has words for are listed: changing the constant to | ||
| 36 | // anything else fails here first, which is the prompt to decide the wording | ||
| 37 | // once and then let the test above find every site that needs it. | ||
| 38 | func graceProse(t *testing.T, d time.Duration) string { | ||
| 39 | t.Helper() | ||
| 40 | if d == time.Hour { | ||
| 41 | return "an hour" | ||
| 42 | } | ||
| 43 | t.Fatalf("no documented wording for a grace of %s — write it here, then update the sites this test reads", d) | ||
| 44 | return "" | ||
| 45 | } | ||
| 46 | |||
| 47 | // readRepoFile reads a path relative to the repo root, found by walking up from | ||
| 48 | // this source file to the go.mod. Tests run from the package directory, so the | ||
| 49 | // repo root is not the working directory. | ||
| 50 | func readRepoFile(t *testing.T, rel string) []byte { | ||
| 51 | t.Helper() | ||
| 52 | _, self, _, ok := runtime.Caller(0) | ||
| 53 | if !ok { | ||
| 54 | t.Fatal("runtime.Caller failed") | ||
| 55 | } | ||
| 56 | dir := filepath.Dir(self) | ||
| 57 | for { | ||
| 58 | if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { | ||
| 59 | break | ||
| 60 | } | ||
| 61 | parent := filepath.Dir(dir) | ||
| 62 | if parent == dir { | ||
| 63 | t.Fatal("could not find repo root (go.mod) above " + self) | ||
| 64 | } | ||
| 65 | dir = parent | ||
| 66 | } | ||
| 67 | b, err := os.ReadFile(filepath.Join(dir, rel)) | ||
| 68 | if err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | return b | ||
| 72 | } | ||
internal/server/delegation/delegation_test.go
| Old | New | ||
|---|---|---|---|
| @@ -349,7 +349,7 @@ func TestSweepDropsWhatIsNotBeingUsed(t *testing.T) { | |||
| 349 | 349 | ||
| 350 | assert.True(t, hasLive) | 350 | assert.True(t, hasLive) |
| 351 | assert.False(t, hasDead, "an expired delegation is dead weight") | 351 | assert.False(t, hasDead, "an expired delegation is dead weight") |
| 352 | assert.False(t, hasPending, "a keyring is bounded by active tenants") | 352 | assert.False(t, hasPending, "a begin two hours old is well past its grace, and a keyring is bounded by active tenants") |
| 353 | } | 353 | } |
| 354 | 354 | ||
| 355 | func TestConcurrentUseIsSafe(t *testing.T) { | 355 | func TestConcurrentUseIsSafe(t *testing.T) { |
| @@ -391,3 +391,82 @@ func TestCompleteRefusesSourceAddress(t *testing.T) { | |||
| 391 | _, ok := k.Signer("acme") | 391 | _, ok := k.Signer("acme") |
| 392 | assert.False(t, ok, "a refused certificate must leave no delegation behind") | 392 | assert.False(t, ok, "a refused certificate must leave no delegation behind") |
| 393 | } | 393 | } |
| 394 | |||
| 395 | // TestSweepKeepsAPendingBeginThroughTheSigningWindow proves the promise Begin | ||
| 396 | // makes. Signing is a human step, and the sweep ticks on its own schedule, so a | ||
| 397 | // tick landing between Begin and Complete must not rotate the key underneath a | ||
| 398 | // certificate that is already being signed. | ||
| 399 | func TestSweepKeepsAPendingBeginThroughTheSigningWindow(t *testing.T) { | ||
| 400 | clock := now | ||
| 401 | k := New(func() time.Time { return clock }, "ubuntu") | ||
| 402 | |||
| 403 | first, err := k.Begin("acme") | ||
| 404 | require.NoError(t, err) | ||
| 405 | |||
| 406 | clock = now.Add(15 * time.Minute) | ||
| 407 | k.Sweep() | ||
| 408 | |||
| 409 | again, err := k.Begin("acme") | ||
| 410 | require.NoError(t, err) | ||
| 411 | assert.Equal(t, first, again, "a sweep between begin and complete must not rotate the key") | ||
| 412 | } | ||
| 413 | |||
| 414 | // TestSweepDropsAPendingBeginPastItsGrace pins both edges of the grace: a | ||
| 415 | // keypair nobody signed is kept up to it and abandoned at it. | ||
| 416 | func TestSweepDropsAPendingBeginPastItsGrace(t *testing.T) { | ||
| 417 | clock := now | ||
| 418 | k := New(func() time.Time { return clock }, "ubuntu") | ||
| 419 | |||
| 420 | first, err := k.Begin("acme") | ||
| 421 | require.NoError(t, err) | ||
| 422 | |||
| 423 | clock = now.Add(defaultPendingGrace - time.Second) | ||
| 424 | k.Sweep() | ||
| 425 | kept, err := k.Begin("acme") | ||
| 426 | require.NoError(t, err) | ||
| 427 | assert.Equal(t, first, kept, "still inside the grace") | ||
| 428 | |||
| 429 | clock = now.Add(defaultPendingGrace) | ||
| 430 | k.Sweep() | ||
| 431 | fresh, err := k.Begin("acme") | ||
| 432 | require.NoError(t, err) | ||
| 433 | assert.NotEqual(t, first, fresh, "a begin left unsigned past its grace is abandoned") | ||
| 434 | } | ||
| 435 | |||
| 436 | // TestSweepDropsAnExpiredDelegationInsideTheBeginGrace proves the grace covers | ||
| 437 | // only the wait for a certificate. Once one has arrived, its own expiry is what | ||
| 438 | // bounds the entry, and it is dropped even though the Begin behind it is recent. | ||
| 439 | func TestSweepDropsAnExpiredDelegationInsideTheBeginGrace(t *testing.T) { | ||
| 440 | clock := now | ||
| 441 | k := New(func() time.Time { return clock }, "ubuntu") | ||
| 442 | ca := newCA(t) | ||
| 443 | |||
| 444 | pub, err := k.Begin("acme") | ||
| 445 | require.NoError(t, err) | ||
| 446 | _, err = k.Complete("acme", sign(t, ca, pub, certOpts{validBefore: now.Add(30 * time.Minute)}), trusts(ca)) | ||
| 447 | require.NoError(t, err) | ||
| 448 | |||
| 449 | clock = now.Add(45 * time.Minute) | ||
| 450 | require.Less(t, clock.Sub(now), defaultPendingGrace, "the begin must still be inside its grace") | ||
| 451 | k.Sweep() | ||
| 452 | |||
| 453 | k.mu.Lock() | ||
| 454 | _, held := k.tenants["acme"] | ||
| 455 | k.mu.Unlock() | ||
| 456 | assert.False(t, held, "an expired certificate is dead weight whatever the begin grace says") | ||
| 457 | } | ||
| 458 | |||
| 459 | // TestTheWrongKeyRefusalNamesBothWaysTheKeyChanges checks the diagnosis covers | ||
| 460 | // the sweep as well as a restart; a caller told only about restarts has nothing | ||
| 461 | // to act on when the plane has been up for hours. | ||
| 462 | func TestTheWrongKeyRefusalNamesBothWaysTheKeyChanges(t *testing.T) { | ||
| 463 | k := New(fixedNow, "ubuntu") | ||
| 464 | ca := newCA(t) | ||
| 465 | _, err := k.Begin("acme") | ||
| 466 | require.NoError(t, err) | ||
| 467 | |||
| 468 | _, err = k.Complete("acme", sign(t, ca, "", certOpts{key: newCA(t).PublicKey()}), trusts(ca)) | ||
| 469 | require.Error(t, err) | ||
| 470 | assert.Contains(t, err.Error(), "restarts") | ||
| 471 | assert.Contains(t, err.Error(), "unsigned") | ||
| 472 | } | ||
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -123,7 +123,7 @@ export interface paths { | |||
| 123 | }; | 123 | }; |
| 124 | }; | 124 | }; |
| 125 | }; | 125 | }; |
| 126 | /** Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process. */ | 126 | /** Start a delegation: returns the ephemeral public key eitri will authenticate with, the principal the certificate must carry, and the ssh-keygen command that signs it. The key is stable for the life of the process once delegated; a begin left unsigned for an hour is abandoned and the next call returns a new key. */ |
| 127 | post: { | 127 | post: { |
| 128 | parameters: { | 128 | parameters: { |
| 129 | query?: never; | 129 | query?: never; |