Skip to content

PMM-15293 Mint the SEP bearer from the PMM session - #5739

Draft
nachodd wants to merge 5 commits into
PMM-15216from
PMM-15293-sep-session-exchange
Draft

PMM-15293 Mint the SEP bearer from the PMM session#5739
nachodd wants to merge 5 commits into
PMM-15216from
PMM-15293-sep-session-exchange

Conversation

@nachodd

@nachodd nachodd commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Ticket number: PMM-15293

Feature build: SUBMODULES-0

Stacked PR — base is PMM-15216, not main. Review the four-commit diff, not the full branch. Merge order: #5728 (PMM-15288) → #5653 (PMM-15216) → this one.

What

Authenticate the embedded SEP UI as the actual PMM user by exchanging the PMM session for a short-lived SEP bearer, replacing the interim server-side token injection.

Why

ui/apps/pmm/src/sep/bootstrap.ts registered setTokenProvider(() => null) and the dev proxy injected PMM_DEV_SEP_INTERNAL_TOKEN server-side. That authenticates as SEP's internal service principal, which hardcodes is_admin = False, so every admin-gated SEP surface returns 403. Fine for verifying the UI migration; not shippable.

SEP's side shipped in SEP-1692: POST /api/oauth/session/exchange is same-origin through PMM's proxy, so the browser attaches pmm_session automatically. SEP validates it against Grafana, maps the org role, and returns access_token + expires_in. No cookie is set and no refresh token is issued.

Acceptance criteria

AC Where
Bearer held in memory only; re-exchanged before expiry; concurrent refreshes coalesced sepTokenStore.ts, single-flight in @sep/api
Recover on a 401 from a SEP call 401 retry in both transports
A 401 from the exchange is "not signed in", not a retry loop sticky sessionRejected
Fail closed on the credential failClosed() — every failure path drops the bearer first
A failed bootstrap exchange shows a signed-out state phase: 'signedOut' | 'unreachable'
A failed background renewal does not tear the UI down phase stays ready; page never unmounts
A terminal renewal failure is surfaced without destroying state inline notice; transient retried with backoff

How

@sep/api — token-minter seam (commits 1, 3)

refreshAccessToken() hardcoded POST /oauth/refresh as the only way to obtain a token. PMM's embedding has no refresh cookie, so every recovery attempt would 401 there.

setTokenMinter() replaces just that call. The default is unchanged, so the standalone SPA behaves exactly as before. Everything downstream was already minter-agnostic: the single-flight coalescer, the axios 401 retry, the setOnRefreshed notification.

Two supporting changes:

  • /oauth/session* is excluded from the 401 retry branch. Minting is single-flighted, so routing a mint's own 401 back through the retry interceptor hands it the very promise it is running inside — an await on itself that never settles. The unauthorized handler still fires for those endpoints: a rejected exchange means "not signed in" and the auth layer needs to hear it. There is a regression test that would time out if this guard were removed.
  • The openapi-fetch transport gained the 401 retry the axios one already had. It previously only reported unauthorized, so typed hooks (useCurrentUser and every generated-path hook) could not recover at all. fetch consumes a Request's body, so the middleware stashes a clone taken before dispatch and replays that; the replay goes through raw fetch so it cannot re-enter the middleware and loop. Only replay-eligible requests are cloned (thanks @copilot).

PMM — session exchange (commit 2)

  • sepTokenStore.ts holds the bearer in memory only — no localStorage, no sessionStorage, no query cache. Renews 30s ahead of the 5-minute expiry; the transports' 401 retry is the backstop for a throttled background tab that misses the window. Concurrency is delegated to refreshAccessToken(), so a burst of parallel SEP requests triggers one exchange.
  • SepAuthGate triggers the first exchange when a SEP route mounts rather than at app startup. The UI has no PMM_ENABLE_SEP flag, so an eager exchange would hit SEP on every page load for every PMM user. It also closes a race the provider cannot: setTokenProvider is synchronous, so a plugin's first queries would otherwise fire before the exchange resolved. It sits inside SepPage's existing admin check, so the exchange only runs for a user allowed on the page.
  • The dev proxy no longer injects the internal token on /api/oauth/*. Overwriting Authorization there would authenticate the exchange as the service principal and mask whether the cookie path works at all.

Fail closed, without discarding user work (commit 4)

These two rules pull in opposite directions, and reconciling them is most of this commit.

Fail closed. Every failure path runs through failClosed(), which drops the bearer before doing anything else. getSepToken() independently returns null past the expiry, so nothing proceeds on a stale, expired, or unverified credential, and there is no cached value to fall back on. A rejected session sets a sticky sessionRejected that refuses minting outright — the loop is cut before a request is made, not after it fails.

Never destroy user work. The failure lands at one of two altitudes, chosen by whether a bearer has ever been held:

Before a bearer exists After the page is mounted
Rejected session (401) phase: 'signedOut' — takes over the page notice: 'signedOut' — inline, page untouched
Cannot reach SEP phase: 'unreachable' — takes over the page 4 quiet backoff retries, then notice: 'unreachable'

At load there is no work in progress, so a full-page state is right. Once mounted, it is not: the previous revision moved the phase to signedOut on a rejected renewal, which unmounted the plugin and threw away whatever was half-typed into it. The page now stays exactly as it is and the failure appears beside it, telling the user submissions will fail and offering a retry.

Transient renewal failures back off at 2s, 4s, 8s, 16s and only surface if all four fail. A 401 skips the backoff entirely — the session is genuinely gone, so retrying would just repeat the rejection.

getSepAuthStatus() became getSepAuthState(), returning a cached { phase, notice } snapshot so useSyncExternalStore does not re-render subscribers on a no-op.

Testing

pnpm check-types, pnpm lint (0 errors), pnpm format:check, pnpm test — 1170 tests pass. 36 are new:

  • packages/sep/api/tests/client.test.ts — minting through the registered minter, coalescing a burst of 401s into one exchange, the self-await regression guard, a minter resolving null, restoring the default.
  • packages/sep/api/tests/typed-client.test.ts — mint-and-replay, replaying a request body (the clone is the crux), replaying at most once, one mint across concurrent 401s, no recovery attempt on a mint endpoint's own 401.
  • apps/pmm/src/sep/sepTokenStore.test.ts — mocks only postSessionExchange, so the real @sep/api single-flight and unauthorized wiring are exercised. Covers acquisition, the synchronous provider, coalescing, no web-storage writes, snapshot stability, and one group per AC: failing closed (expiry, both renewal failure kinds, sticky refusal), bootstrap failure, and renewal on a mounted page (quiet backoff, surfacing only on persistence, terminal-401 immediacy, never leaving ready).
  • apps/pmm/src/sep/SepAuthGate.test.tsx — bootstrap paths, plus the non-destructive ones: a rejected session mid-session keeps the page mounted and preserves typed-in form state, and a successful retry clears the notice with the form still intact.

Not verified end to end

Blocked by PMM-15280 ([BE] Provision a Grafana service account for SEP when PMM_ENABLE_SEP=1). SEP validates the session using its own Grafana service-account token, which the embedded profile deliberately ships without. Until that credential is provisioned the exchange returns 401 for everything, so a live run today exercises the signedOut path and nothing else. The happy path exists only in tests.

Known gap, by scope

The dev proxy still injects PMM_DEV_SEP_INTERNAL_TOKEN on non-OAuth paths. In dev, that means a request sent while the store holds no bearer arrives at SEP authenticated as the service principal instead of failing — a fallback to an injected credential, which is exactly what "fail closed" rules out. It does not apply in production, where no injection exists, and retiring the injection is the agreed follow-up rather than part of this PR. Worth knowing when reading dev-environment behaviour.

Out of scope / follow-ups

  • Retiring the server-side SEP_INTERNAL_TOKEN injection from the proxy configuration entirely (closes the gap above).
  • Porting the setTokenMinter seam back to percona/SEP so the next frontend sync does not clobber it.

Related work

nachodd added 2 commits August 5, 2026 18:43
`refreshAccessToken()` hardcoded `POST /oauth/refresh` as the only way to
obtain a token. An embedded host that owns the session — PMM — has no
refresh cookie, so every recovery attempt would 401 there.

`setTokenMinter()` replaces just that call; the default is unchanged, so
the standalone SPA behaves exactly as before. Everything downstream is
minter-agnostic already: the single-flight coalescer, the axios 401
retry, and the `setOnRefreshed` notification.

Two supporting changes:

The 401 retry now skips `/oauth/session*` as well as `/oauth/refresh`.
Minting is single-flighted, so routing a mint's own 401 back through the
retry interceptor would hand it the very promise it is running inside —
an await on itself that never settles. The unauthorized handler still
fires for those endpoints: a rejected exchange means "not signed in" and
the auth layer needs to hear it.

The openapi-fetch transport gained the 401 retry the axios one already
had; it previously only reported unauthorized, so typed hooks could not
recover at all. `fetch` consumes a Request's body, so the middleware
stashes a clone taken before dispatch and replays that. The replay goes
through raw `fetch` so it cannot re-enter the middleware and loop.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
The embedded SEP UI authenticated as SEP's internal service principal:
the token provider returned null and the proxy injected
PMM_DEV_SEP_INTERNAL_TOKEN server-side. That principal hardcodes
`is_admin = False`, so every admin-gated SEP surface answered 403.

It now authenticates as the actual PMM user. `sepTokenStore` exchanges
the ambient `pmm_session` cookie for a short-lived SEP bearer via
`POST /api/oauth/session/exchange` (SEP-1692) and holds it in memory
only — no localStorage, no sessionStorage, no query cache. It renews 30s
ahead of the 5-minute expiry, and the transports' 401 retry covers the
case where a throttled background tab misses that window. Concurrency is
delegated to `refreshAccessToken()`, so a burst of parallel SEP requests
triggers one exchange.

A 401 from the exchange itself is sticky: minting is refused until the
user retries, so a rejected session cannot drive an exchange loop.

`SepAuthGate` triggers the first exchange when a SEP route mounts rather
than at app startup — the UI has no PMM_ENABLE_SEP flag, so an eager
exchange would hit SEP on every page load for every PMM user. It also
closes a race the provider cannot: `setTokenProvider` is synchronous, so
a plugin's first queries would otherwise fire before the exchange
resolved.

The dev proxy no longer injects the internal token on `/api/oauth/*`.
Overwriting Authorization there would authenticate the exchange as the
service principal and mask whether the cookie path works at all.
Retiring the injection entirely is a follow-up.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
@nachodd
nachodd requested a review from a team as a code owner August 6, 2026 12:12
@nachodd
nachodd requested review from Copilot, fabio-silva and mattiasimonato and removed request for a team August 6, 2026 12:12
@nachodd
nachodd marked this pull request as draft August 6, 2026 12:19

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.

Pull request overview

This PR updates the embedded SEP integration in PMM UI to authenticate SEP requests as the active PMM user by exchanging the PMM session cookie for a short-lived SEP bearer token, replacing the interim dev-proxy server-side token injection approach. It does this by introducing a pluggable “token minter” seam in @sep/api, adding 401 replay recovery to the typed openapi-fetch client, and wiring PMM’s SEP routes through an in-memory token store + auth gate.

Changes:

  • Add a pluggable token-minter API (setTokenMinter) and broaden “mint endpoint” detection to avoid retry self-deadlocks.
  • Add one-shot 401 recovery + request replay (including body replay) for the typed openapi-fetch client.
  • Implement PMM’s in-memory SEP bearer store + route gate, and adjust the dev proxy to avoid overriding OAuth/session exchange requests.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ui/packages/sep/api/tests/typed-client.test.ts Adds typed-client 401 recovery/replay tests (including request body replay and mint-endpoint guard).
ui/packages/sep/api/tests/client.test.ts Adds axios client tests for the token-minter seam and self-await regression guard.
ui/packages/sep/api/src/typed-client.ts Implements typed-client 401 recovery via token mint + raw fetch replay using a pre-dispatch Request clone.
ui/packages/sep/api/src/index.ts Exports setTokenMinter and MintedToken from the package surface.
ui/packages/sep/api/src/client.ts Introduces MintedToken, setTokenMinter, broadens mint-endpoint detection, and routes refresh through the minter.
ui/packages/sep/api/README.md Documents the token-minter seam and PMM embedded-session wiring.
ui/apps/pmm/vite.config.ts Prevents dev-proxy internal-token injection from overriding /api/oauth/* routes (needed for session exchange).
ui/apps/pmm/src/sep/sepTokenStore.ts New in-memory SEP bearer store with renewal scheduling and sticky signed-out behavior on rejected session.
ui/apps/pmm/src/sep/sepTokenStore.test.ts Unit tests for token acquisition, renewal, storage guarantees, concurrency coalescing, and sticky signed-out handling.
ui/apps/pmm/src/sep/SepPage.tsx Wraps SEP routes in SepAuthGate so the initial exchange happens on SEP route mount.
ui/apps/pmm/src/sep/SepAuthGate.tsx New gate that blocks SEP content until the bearer is minted; provides retry UI for failures.
ui/apps/pmm/src/sep/SepAuthGate.test.tsx Tests gating behavior (withhold children, sticky signed-out, retry, transient error messaging).
ui/apps/pmm/src/sep/SepAuthGate.messages.ts New copy for the SEP auth gate’s loading/error/signed-out states.
ui/apps/pmm/src/sep/bootstrap.ts Wires @sep/api to the PMM token store (provider + minter + refreshed + unauthorized callbacks).

Comment thread ui/packages/sep/api/src/typed-client.ts
nachodd added 2 commits August 6, 2026 10:14
`onRequest` cloned every outbound Request so a 401 could be replayed,
including the minting and login endpoints that `onResponse` explicitly
excludes from the retry. Cloning buffers the body, and those clones were
never going to be used.

Both call sites now share one `isReplayEligible` predicate, so the clone
and the retry cannot drift apart.

Raised by Copilot on #5739.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
Reworks how the store reports failure, against the updated ACs. Two
rules now shape it, and they pull in opposite directions.

Fail closed. Every exchange failure drops the bearer, so no request can
proceed on a stale, expired, or unverified credential, and there is no
cached value to fall back on. A session SEP has rejected stays sticky:
minting is refused outright until the user retries, so a rejection can
never drive an exchange loop.

Never destroy user work. The failure now lands at one of two altitudes.
Before a bearer has ever been held the page does not exist yet, so a
bootstrap failure takes the page over — there is nothing to preserve.
Once mounted the page stays mounted and the failure becomes an inline
notice beside it. Previously a background renewal being rejected moved
the phase to `signedOut`, which unmounted the plugin and threw away
whatever was half-typed into it.

The two are reconciled by keeping the bearer and the reporting separate:
`failClosed` always drops the credential, then chooses between a phase
change and a notice based on whether the page is up.

A renewal that fails for a reason that may not repeat is now retried
quietly with backoff — 2s, 4s, 8s, 16s — and only surfaces if all four
attempts fail. A 401 skips the backoff: the session is genuinely gone
and retrying would only repeat the rejection, so the user is told at
once, non-destructively, that submissions from this page will fail.

`getSepAuthStatus()` is replaced by `getSepAuthState()`, returning a
cached `{ phase, notice }` snapshot so `useSyncExternalStore` does not
re-render subscribers on a no-op. The old `error` phase is renamed
`unreachable`, matching the notice of the same name.

Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
nachodd added a commit to percona/SEP that referenced this pull request Aug 6, 2026
Upstreams the change PMM needed for the embedded UI (percona/pmm#5739),
so the next sync of this tree into PMM does not clobber it.

`refreshAccessToken()` hardcoded `POST /oauth/refresh` as the only way to
obtain a token. That is right for this SPA, but PMM embeds SEP with no
refresh cookie at all — it trades its own session cookie for a bearer via
`POST /oauth/session/exchange` (SEP-1692) — so there the default 401s on
every recovery attempt, and the whole retry machinery is dead weight.

`setTokenMinter()` replaces just that call and defaults to the existing
one, so nothing changes here. Everything downstream was already
minter-agnostic: the single-flight coalescer, the axios 401 retry, and
the `setOnRefreshed` notification all work the same whichever endpoint
produced the token.

`isTokenMintRequest` composes the existing `isRefreshRequest` and
`isSessionRequest` guards, which the axios retry condition already
listed separately, and is exported so the typed client shares one
definition. Keeping them together matters: minting is single-flighted,
so letting a mint's own 401 into the retry path would hand the
interceptor the very promise it is running inside.

The `openapi-fetch` transport gained the 401 retry the axios one already
had. It previously only reported unauthorized, so every typed hook —
`useCurrentUser` and all the generated-path ones — surfaced an expired
token as a failure instead of recovering from it. This is a fix for both
deployments, not just the embedded one. `fetch` consumes a Request's
body, so the middleware stashes a clone taken before dispatch and
replays that; only replay-eligible requests are cloned, and the replay
goes through raw `fetch` so it cannot re-enter the middleware and loop.
yyyyyyyan pushed a commit to percona/SEP that referenced this pull request Aug 7, 2026
)

The CodeRabbit review of the SEP-UI-into-PMM migration PR
([percona/pmm#5653](percona/pmm#5653),
PMM-15216) raised these findings against the ported copy of `@sep/api` /
`@sep/framework`. Every one of them is present in the original, so they
are fixed here too — otherwise the next sync in either direction
re-introduces them. PMM-only wiring (route constants, the route-level
admin gate, the dev-proxy env variables) is out of scope.

## Token-minter seam (upstreamed, not a review fix)

The last commit is a different kind of change from the rest of this PR,
so it is called out separately: it upstreams a capability PMM needed
([percona/pmm#5739](percona/pmm#5739),
PMM-15293) rather than fixing a review finding. It lands here for the
same reason as everything else — otherwise the next sync clobbers it.

`refreshAccessToken()` hardcoded `POST /oauth/refresh` as the only way
to obtain a token. That is correct for this SPA. It is not workable for
PMM, which embeds SEP with no refresh cookie at all: it trades its own
session cookie for a bearer through `POST /oauth/session/exchange`
(SEP-1692), so the default 401s on every recovery attempt and the entire
retry path is dead weight there.

- **`setTokenMinter()`** replaces just that one call, defaulting to the
existing behaviour — **nothing changes for a standalone SEP
deployment**. Everything downstream was already minter-agnostic: the
single-flight coalescer, the axios 401 retry, and the `setOnRefreshed`
notification do not care which endpoint produced the token.
- **`isTokenMintRequest`** composes the existing `isRefreshRequest` and
`isSessionRequest` guards — which the axios retry condition already
listed separately — and is exported so the typed client shares one
definition rather than growing a second copy. Keeping them together is
load-bearing: minting is single-flighted, so letting a mint's own 401
into the retry path hands the interceptor the very promise it is running
inside, an await on itself that never settles. There is a regression
test that times out rather than fails if that guard is lost.
- **The `openapi-fetch` transport gained the 401 retry the axios one
already had.** This one is a fix for *both* deployments: it previously
only reported unauthorized, so every typed hook — `useCurrentUser` and
all the generated-path ones — surfaced an expired token as a failure
instead of recovering. `fetch` consumes a Request's body, so the
middleware stashes a clone taken before dispatch and replays that; only
replay-eligible requests are cloned, and the replay goes through raw
`fetch` so it cannot re-enter the middleware and loop.

`@sep/api` grows 10 tests for this: minting through a registered minter,
coalescing a burst of 401s into one exchange, the self-await regression
guard, a null-resolving minter, restoring the default, and on the typed
side mint-and-replay, replaying a request body, replaying at most once,
one mint across concurrent 401s, and no recovery attempt on a minting
endpoint's own 401.

**Correctness**

- **`refreshAccessToken`** called the injected `_onRefreshed` handler
inside the async executor, so a synchronous throw from it rejected the
shared `refreshInFlight` promise — every awaiting caller saw a failed
refresh, and a force-logout, for a cookie rotation that had already
succeeded on the backend. The function's own comment says this must not
happen.
- **`SchemaFormRenderer`** returned from `handleFormSubmit` on a section
violation, which react-hook-form reads as a *successful* submit
(`isSubmitSuccessful = true`). `useUnsavedChangesGuard` is `isDirty &&
!isSubmitSuccessful` and only re-arms when `submitError` is truthy —
never on this path — so the guard stayed disarmed: no `beforeunload`
prompt, no `UnsavedChangesBlocker`, and the user could navigate away
from a dirty form and lose it. The gate now runs in the submit event
handler, ahead of `handleSubmit`.
- **`normalizeChoiceDefaults`** read and wrote flat keys, but
`flattenSectionFields` also returns `one_of` branch fields, whose names
are dotted paths stored nested. A case-mismatched nested choice value
was never canonicalised and rendered as an empty selection — the exact
failure that function exists to prevent.
- **`AppListPage` / `AppDetailPage`** dereferenced the optional
`list_view` (`schema.list_view!.columns`), reachable through an
unresolved entity route. The list page now renders `Not found`; the
Overview tab falls back to an empty column set and still lists the
task's own fields.
- **`HostSelector`** looked up `errors[name]`, which never resolves for
a dotted branch-field name, so an affected field showed no validation
error.
- **`extractId`** used `Number`, which turns a whitespace-only string
into `0` and accepts `'1.5'` / `'0x10'`. Each result reads as a
resolvable inventory id downstream: `useResolvedServiceField` enables a
lookup for service `0`, and `SchemaSelector` fires `useSchemas({
serviceId: 0 })` for a service that cannot exist.
- **`validationMapper`** submitted `parseInt('2.5', 10)` as `2` and
`parseFloat('3.14invalid')` as `3.14`. Numeric fields now validate with
`Number.isFinite` (plus `Number.isInteger` for `integer`) and coerce
with `Number`.
- **`useTaskLogs`** guarded on `!step`, dropping a log line with `step:
''`. `useExecutionEvents` treats `''` as the stepless bucket and the
viewer labels it "General", so the two streams disagreed and stepless
output was silently lost.

**Stability**

- **`useExecutionEvents`** returned `undefined` from `onerror` for every
non-sentinel error, so `fetchEventSource` retried forever while
`sseError` stayed unset and `sseLoading` stayed true — a persistently
failing endpoint (500, DNS failure) left the panel spinning and
reconnecting indefinitely. Consecutive failures are now counted, reset
on a successful open, and the loop stops with the error surfaced.
- **`StandaloneHostSelector`** disabled its Autocomplete when the hosts
query failed, but `onOpen` holds the only `refetch()` trigger and a
disabled Autocomplete never opens — one failure wedged the control until
the page remounted.
- **A scheduled-task enable/disable toggle** sent `kwargs: '{}'` in a
full PUT, wiping the arguments of any task created with non-default
kwargs. `kwargs` is preserved when the response carries it; `'{}'` stays
the fallback until `PeriodicTaskResponse` declares the field.

**Contract and consistency**

- **`useCascadingField`** cleared with `undefined` while
`buildFormDefaults` seeds these selector types to `''`, and counted `''`
as ready — a downstream selector fetched options for an empty parent,
and a bound MUI input flipped from controlled to uncontrolled.
- **`SchemaDrivenApp`**'s `renderEditForm` invocation omitted
`capabilities`, `submitError` and `fieldErrors`, so a consumer supplying
the slot could render neither the 422 banner nor the inline field
errors. `AppTaskEditPage` already passes all three to the same slot
type.
- **`SchemaListView`** pinned `bgcolor: 'common.white'`, which renders a
white table in dark mode; it now reads the mode's own opaque surface.
- **`FileField`**'s file-picker `IconButton` had no accessible name.
- **`useResolvedServiceField`** discarded `useServices`' error, so
callers could not tell a failed lookup from an id that matched no
service.
- **`packages/framework/test/setup.ts`** was an unreferenced sibling of
`tests/setup.ts` registering the jest-dom matchers but no
`afterEach(cleanup)`. Deleted, so a future `setupFiles` edit cannot
silently drop DOM isolation for the package.

**Deliberately not ported**, matching the decisions recorded on the PMM
PR: production SEP routing / token exchange, flattening dotted app
argument names (they denote nested backend models, which
`coerceFormValues` already produces), converting `ScriptPreviewField` to
TanStack Query, committing untrimmed free-solo input, relocating the
`api` / `atw` test suites, `related_apps` alongside `entities`, and the
hard-coded `Roboto Mono` stacks. `formatCellValue`'s `undefined` guard
is already here — in a better form than the port has, which should go
back to PMM.
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.

2 participants