Skip to content

chore: sync dev with main - #809

Merged
ctkm-aelf merged 1122 commits into
devfrom
chore/sync-dev-with-main-20260520
May 20, 2026
Merged

ctkm-aelf merged 1122 commits into
devfrom
chore/sync-dev-with-main-20260520

Conversation

@ctkm-aelf

Copy link
Copy Markdown
Collaborator

Summary

Brings dev up to date with main. Since the last sync (PR #799), main gained 6 commits that dev was missing. This PR closes that gap.

The merge resolves to main's tree exactly — confirmed dev has no unique content that would be lost (the only patch-id-unique commit on dev, the Vite port fix #243, is already content-equivalent in main).

Commits being synced

Net diff: 31 files, +927 / −203 — concentrated in cli/ (auth preflight + arg plumbing), frontend/src/router.tsx, and backend provider/catalog cleanups.

Note on history

The 31 add/add conflicts seen during the merge are artifacts of PR #799 being squash-merged into dev (which flattened main's history). They are not real content conflicts — every path was resolved to main's version, and the resulting tree is byte-identical to main. Worth considering whether future syncs should fast-forward / merge (not squash) so dev's history stays a descendant of main and avoids this recurring noise.

🤖 Generated with Claude Code

kaiweijw and others added 30 commits May 5, 2026 13:29
…tart-urls-and-broker-scope

fix(mcp): correct hosted BASE_URL, add --scope user, drop broker_binding from public discovery
`nyxid service add --org X` now opens the wizard with the org
pre-selected as owner instead of forcing the scripted terminal path.
Update the prefill-args list and the scripted-flag bypass list to
match.
feat(wizard): support --org in nyxid service add browser wizard
Users running `nyxid login` without `--base-url` or `NYXID_URL` now hit
the hosted https://nyx-api.chrono-ai.fun endpoint instead of erroring.
Override precedence is unchanged: --base-url flag > NYXID_URL env > default.
feat(cli): default login base URL to hosted NyxID instance
Document --passphrase-env and --no-pin-host-key for ssh-credentials add, and
add an SSH error-code table (1011-1015) so agents have a complete reference
for suggesting user-facing fixes.
feat(ssh): node-key authentication mode for credential nodes
The Authentication block (X-API-Key vs Bearer Token explanation) was
gated on the curl example existing, so unknown bot/webhook services
saw only the Base URL with no guidance on how to authenticate.
- Filter `none` and the OpenSSH ext-info-c / strict-kex markers from the
  "supported:" list in unknown-algorithm errors so users only see
  algorithms they can actually pass.
- Reword the host-key error from "unknown" to "is not allowed" for
  algorithms that parse but are outside the Ed25519/RSA/ECDSA families.
- Drop `num_args = 0..` on the eight allowlist flags so `--kex` without
  a value is rejected by clap instead of silently parsing as empty.
- Reject `set-algos` invocations that specify no list and no reset flag
  to avoid no-op writes that print "updated".
Wire-up of the new TwitterOAuthGuidance component into the CLI pairing
OAuth credential step changed the wizard source closure; rebuild the
embedded bundle so the freshness check passes.
Add the new --kex / --host-key / --cipher / --mac flags on
`ssh-credentials add` and the `set-algos` subcommand to the nyxid
skill reference and the SSH node-key auth doc, with a worked RouterOS
example. Update the 1013 SshNodeExecChannelClosed row to point at
algorithm pinning as a debug step for legacy appliances that fail
KEX init under russh defaults.
ctkm-aelf and others added 28 commits May 15, 2026 15:34
…deep-link actions (#761)

* feat(frontend): make the dashboard interactive — URL-driven tabs and deep-link actions

- Centralize tabbed-page state in lib/url-tabs.ts; settings / consents /
  integration-guide / ai-setup / keys / org-detail all read tab selection
  from search params. Tabs survive refresh, deep links are shareable, and
  browser back/forward navigates between tabs.
- AccountPostureCard rows on the dashboard become real links pointing at
  the destination that completes the step (MFA -> /settings?tab=security,
  Connect a Service -> /keys?action=add-service, Create API key ->
  /keys?tab=nyxid&action=create-key).
- CommandItem grows optional `search` and `onSelect`; the palette's three
  action entries ("Connect a Service", "Create API key", "Review approvals")
  now actually trigger the right dialog instead of just landing on a page.
- Whitelist `action` on the /keys route so the deep links above survive
  TanStack search-param validation; add validators to settings / consents /
  integration-guide / ai-setup / org-detail for the same reason.

Verified: type check clean, 567 unit tests pass, headless Chrome confirms
the palette -> dialog round-trip for both add-service and create-key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(frontend): mobile-nav search guards optional `to` on CommandItem

The mobile-nav search reuses `ALL_ITEMS` from the command palette
(`SEARCH_ITEMS`). After making `CommandItem.to` optional so action entries
can run an `onSelect` callback instead of routing, two call sites in
MobileNav still assumed `to` was a non-empty string and broke CI:

  - the filter dereferenced `item.to.toLowerCase()` (TS18048)
  - the click handler passed `item.to` directly to a `(to: string)` arg
    (TS2345)

Mirror the palette's item-aware selection: optional-chain in the filter,
prefer `onSelect` over navigation, and forward `item.search` so the
action deep-links round-trip the same way the palette does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…boarding chrome

- Add shared ViewToggle component with sliding pill animation and per-page
  localStorage persistence via useViewMode(key) hook
- Add grid/table view toggle to 5 pages: Services & Credentials, Organizations,
  Credential Nodes, Channel Bots, Access & Authorizations
- Make each tab's view mode independent (keys-services vs keys-agent, etc.)
- Mobile always shows card/grid view; toggle hidden below md breakpoint
- Fix service card badges below title, subtitle truncation, info grid mt-auto
- Fix consistent table row heights and badge ordering (Active before SSH)
- Add profile dropdown and GitHub link to onboarding takeover top bar
- Add auth-hero-4.png to login page image carousel rotation
- Fix TabsList border width with w-fit
- Fix Register Node button size to match standard CTA dimensions
- Ensure cursor-pointer on all clickable toggle buttons and table rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix(frontend): UI consistency sweep v5 — view toggles, card fixes, onboarding
…de-grant

feat(devices): device-code grant for headless provisioning
…6-device-code-grant

Revert "feat(devices): device-code grant for headless provisioning"
…#776)

* feat(oauth): multi-connection OAuth — additive, no migration

Lets users add multiple codex / Lark services per provider instead of the
second add silently aliasing onto the first. Every add mints its own
connection_id; tokens are written and refreshed per-connection.

Schema (additive, backward-compat — all Option fields, no migration):
- connection_id on UserProviderToken, UserApiKey, OAuthState
- partial unique index on UserApiKey.connection_id + startup dup audit

New helpers (user_*_service):
- write_oauth_tokens_to_key — callback writes the token straight onto the
  connection's UserApiKey, skipping user_provider_tokens; respects
  terminal statuses so a late/duplicate callback can't resurrect a key
- refresh_user_api_key_in_place — per-connection refresh, BYO + provider-
  owned client creds, handles Lark JSON body + {code} error shape

Flow wiring:
- connection_id threaded initiate -> OAuthState -> callback
- handle_oauth_callback + store_device_code_tokens branch on it
- proxy_service refresh branches on it
- silent-alias removal in unified_key_service::create_key (POST + PUT);
  fresh connection_id minted per add for oauth2 / device_code providers
- key_id query param on the OAuth + device-code initiate handlers; the
  wizard passes the placeholder id so the callback scopes its write

Isolation (a legacy refresh must not clobber a multi-connection key):
- sync_provider_token_to_api_keys + fail_pending_placeholders_for_provider
  scoped to connection_id: null
- reconcile_pending_oauth_placeholder — Pass 1 legacy-only, Pass 2
  connection-scoped with a 60s fresh-placeholder grace guard

CLI wizard bundle rebuilt to embed the key_id threading.

Known gap (tracked in designs/codex-lark-multi-add-additive.md §4.11):
per-Custom-App credentials for multi-connection Lark are not yet wired —
all of a user's Lark connections currently share one Custom App and
connection-scoped Lark refresh has no cred source. Requirements + fix
plan discussed; follow-up PR.

backend: 1890 tests pass, clippy clean, fmt clean. frontend type-checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(oauth): wire multi-connection end-to-end — BYO Custom Apps, dashboard, CLI

Lands the write side, surface side, and observability that the prior
multi-connection scaffolding commit (a7938d4) deferred. After this
patch a second `nyxid service add codex` / `add lark` actually creates
an independent connection — no aliasing, no silent breakage at the
first refresh ~2h later — across BOTH the dashboard "Add Service"
dialog AND the `nyxid service add --oauth/--device-code` CLI commands.
Strictly additive: no schema migrations, no index drops, no required-
field additions, no breaking changes.

Design source of truth:
  designs/multi-connection-custom-app-credentials.md  (the BYO write
                                                        side completing
                                                        codex-lark-
                                                        multi-add-
                                                        additive.md §3.1
                                                        and §4.7)

BYO Custom App credentials (Lark / Feishu / Twitter — credential_mode:
"user"). `UserApiKey.user_oauth_client_*_encrypted` already existed on
the branch but nothing populated them; refresh broke at first expiry.
This commit:

  - Extends `CreateApiKeyParams` to accept `oauth_client_id` /
    `oauth_client_secret`; `create_api_key` encrypts both onto the new
    row.
  - `POST /keys` accepts three new optional fields: `oauth_client_id`,
    `oauth_client_secret`, `copy_oauth_client_from`. Mutual-exclusion
    + pair-completeness validated at the handler; provider compat
    (`supports_user_credentials`) gated in `create_key`; required-when-
    BYO-provider gated at the multi-connection mint site.
  - New `OauthClientCredentialsInput` enum + server-side resolver
    (`resolve_oauth_client_credentials_input`) decrypts the source key
    plaintext for the `CopyFrom` path (ownership-checked, returns
    NotFound for foreign owners so existence isn't leaked).
  - `user_credentials_service::resolve_connection_oauth_credentials`
    reads BYO off the connection's `UserApiKey`. `initiate_oauth_connect`,
    `request_device_code`, and `handle_oauth_callback` all consult it
    first when `connection_id` is set so authorize, exchange, and refresh
    use the SAME Custom App throughout the flow.

API surfacing (so the FE / monitoring can tell connections apart):

  - `KeyView` / `KeyResponse` now include `connection_id` (plaintext)
    and `oauth_client_id` (async-decrypted via the new
    `enrich_view_with_oauth_client_id` helper). `list_keys` + `get_key`
    take `&EncryptionKeys` for the decrypt pass; all callers updated.
  - Proxy audit events (`event_data.connection_id`) and a
    `x-nyxid-connection-id` response header on all three proxy paths
    (direct HTTP, codex transport, node-routed) so dashboards can
    correlate "which Lark Custom App made this call" without a second
    DB read.
  - New `key_refresh_failed` audit event fired from
    `refresh_user_api_key_in_place` carrying `connection_id`,
    `provider_config_id`, `api_key_id`, and the truncated provider
    error.

Dashboard + CLI multi-connection wiring (closing the gap so the
non-wizard surfaces actually use the new path):

  - `useInitiateOAuth` / `useInitiateDeviceCode` accept an optional
    `keyId` that becomes `&key_id=…` on the initiate URL. Without it
    the OAuth state has no `connection_id` and the callback falls back
    to the legacy `user_provider_tokens` path, stranding the multi-
    connection placeholder.
  - Dashboard `AddKeyDialog` threads the placeholder id into both the
    OAuth and device-code initiate calls, and routes BYO Custom App
    credentials from `OAuthCredentialsStep` through parent state into
    the same `POST /keys` body.
  - CLI gains `--oauth-client-id`, `--oauth-client-secret` (hidden),
    and `--oauth-client-secret-env <VAR>` flags. `run_oauth_add` and
    `run_device_code_add` thread BYO into `POST /keys` and append
    `&key_id=…` to the initiate URL.
  - CLI rejects upfront: BYO + `--via-node` (node-routed flows manage
    creds locally), BYO + `--device-code` (all current device-code
    providers are admin-mode), unpaired BYO, and empty-but-set
    `--oauth-client-secret-env` (would surface as a generic backend
    400 otherwise).

Correctness fixes raised by the independent second-opinion review:

  - UTF-8 byte-boundary panic — `&body[..body.len().min(200)]` panics
    when a multibyte UTF-8 character straddles byte 200, which is the
    common case for Lark / Feishu Chinese error responses on refresh
    failure. Fixed at all four sites in `user_token_service.rs` with
    `.chars().take(200).collect::<String>()`.
  - Refresh-race CAS — failure writes in `refresh_user_api_key_in_place`
    used `update_one({_id: X})` with no precondition, so a losing
    concurrent refresh could clobber a sibling's freshly-active row
    with `status: "failed"` and lock the user out until manual
    re-auth. Failure writes now CAS on `(_id, updated_at, status $nin
    [revoked, failed])`; lost-race writes log and bail. The success
    write also gains the `status $nin [revoked, failed]` predicate so
    a sibling revoke can't be resurrected.
  - Legacy-table corruption — the dashboard's OAuthCredentialsStep and
    the wizard's `saveUserCredentials` were both calling
    `PUT /providers/{id}/credentials` alongside the new `POST /keys`.
    That table is single-row per `(user, provider)`; a user adding
    their second Lark Custom App via dashboard would overwrite their
    first connection's Custom App secret, silently breaking its
    legacy refresh path. Multi-connection refresh reads BYO directly
    off the `UserApiKey`, so the PUT is no longer needed for new
    adds. Removed it from both surfaces; the legacy endpoint stays
    available for admin tooling.
  - Partial unique index on `oauth_states.connection_id` (filter
    `{$gt: null}` since `connection_id: None` serializes as BSON
    null). Prevents duplicate in-flight states from a double-clicked
    "Connect" producing two successful callbacks that overwrite each
    other's freshly-rotated refresh tokens.

Out of scope (tracked):

  - Provider-side identity capture (Codex email, Lark App identity)
    needs id_token / userinfo plumbing; deferred.
  - Real `copy_oauth_client_from` picker UX — backend supports it,
    frontend currently doesn't surface a picker; falls back to the
    re-paste form for v1.

Tests + checks:

  - 1902 backend + 354 CLI + 1 wizard-freshness + 565 frontend tests
    pass; 0 failures. (12 new backend tests for create_api_key BYO
    write, resolve_connection_oauth_credentials, create_key BYO
    happy/sad paths, copy_oauth_client_from happy/sad paths.)
  - `cargo clippy --all-targets -- -D warnings` clean.
  - `cargo fmt --check` clean.
  - `npm run build` + `npm run lint` clean (0 errors, only pre-
    existing warnings).
  - Wizard bundle rebuilt (`cli/src/wizard/bundle-meta/index.hash`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli-wizard): allow BYO Custom App fields through wizard proxy

The wizard's local HTTP proxy enforces a strict body-field allowlist for
`POST /api/v1/keys` (cli/src/wizard/server.rs:1314). Commit 3604b4e
added `oauth_client_id` / `oauth_client_secret` /
`copy_oauth_client_from` to the backend `CreateKeyRequest` so a wizard
add can store per-Custom-App credentials on the new UserApiKey, but the
matching allowlist was never updated. Effect: every Lark / Feishu /
Twitter wizard add through the new BYO path 400s with

  proxy: unexpected field 'oauth_client_id' for POST /api/v1/keys

before the request reaches the backend. The placeholder is never
created, so the rest of the multi-connection flow can't even start.

Fix: add the three field names to the allowlist with a comment
explaining what they're for and that `copy_oauth_client_from` is the
alternative shape (clone an existing key's encrypted creds instead of
retransmitting the secret).

Server-side validation still enforces all type / length / shape rules —
this only loosens the wizard's pre-flight filter, which exists to keep
arbitrary client-supplied fields out of backend calls the wizard
proxies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli-wizard): re-apply OAuth polling cleanup fix (regression of #653)

OAuthFlow makes ZERO network requests to GET /api/v1/keys/{id} after
entering the "waiting" phase. The wizard's network tab shows only
heartbeat traffic — no polling at all. Backend has the placeholder
marked active; the wizard never notices, leaving the Lark / codex /
Twitter wizard hung on "Waiting for provider authorization…" forever.

This is the SAME bug as #653 / PR #723 (commit bdbb4ca). The fix was
reverted by the UI consistency sweep (5f9a67e / PR #733), which even
admitted it in its own diff:

  "Skip auth-flows polling regression test (depends on reverted #653 fix)"

Root cause (unchanged from #723's diagnosis):

  OAuthFlow's main useEffect uses [phase] deps. Its cleanup sets
  cancelledRef.current = true. The async function inside the effect
  calls setPhase("waiting") and immediately awaits pollUntilActive(...).
  React's concurrent renderer flushes the queued setPhase during the
  polling loop's first `await sleepMs(2000)`, fires the useEffect
  cleanup, and flips cancelledRef = true. When the sleep resolves the
  polling loop's next `if (isCancelled()) return;` check exits without
  ever issuing the GET.

DeviceCodeFlow doesn't have this bug because its main useEffect uses []
deps, so its cleanup only fires on actual unmount.

Fix (verbatim re-application of bdbb4ca):

- Remove `cancelledRef.current = true;` from the [phase]-deps useEffect
  cleanup. Keep the local `cancel` flag (still needed to abort the
  previous async run when the effect re-fires).
- Add a separate useEffect with [] deps whose cleanup is the only place
  that flips cancelledRef = true. Mirrors DeviceCodeFlow's pattern,
  fires only on actual component unmount.
- Expand the CRITICAL: do NOT comment block to call out the #733
  regression explicitly so the next UI sweep is less likely to revert
  it a second time.

Test: un-skip the regression guard in auth-flows.test.tsx:

  it("actually fires GET /keys/<id> while the placeholder is
      pending_auth (issue #653 root cause)")

This test renders OAuthFlow with mocks that keep the placeholder in
pending_auth (so the active short-circuit doesn't fire) and waits up
to 5s for the polling loop to issue its first GET. Pre-fix it fails
(zero GETs); post-fix it passes (~2.1s for the first poll cycle).

Verification against the live database (this branch):

  Two Lark services added back-to-back through the wizard
  (api-lark-7 "lark test 01" + api-lark-8 "lark 002") each landed
  with distinct connection_ids, distinct encrypted access/refresh
  tokens, AND distinct encrypted BYO Custom App credentials —
  proving the full multi-connection BYO chain works end-to-end once
  polling actually fires.

Bundle rebuilt: cli/src/wizard/assets/index.html +
cli/src/wizard/bundle-meta/index.hash updated to match the fixed
frontend source closure; wizard_bundle_freshness test passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(add-key-dialog): fix stale comment about legacy PUT call

The parent-side comment on `OAuthCredentialsStep.onComplete` claimed
"The legacy PUT to /providers/{id}/credentials still happens inside
OAuthCredentialsStep so single-connection flows and admin tooling keep
working." That's the OPPOSITE of reality post-3604b4ed — the PUT was
removed precisely to avoid corrupting any pre-existing single-connection
user's shared `user_provider_credentials` row when they add a SECOND
Custom App connection via the dashboard (PR body correctness fix #3).

The component-level doc on the `onComplete` prop itself
(add-key-dialog.tsx:1798) already correctly explains the no-PUT
invariant. This commit just brings the parent-side caller comment in
line with it so a future contributor reading the call site doesn't
infer they can rely on the PUT having happened.

No behavioral change. No bundle rebuild needed
(add-key-dialog.tsx is not in the wizard bundle closure; verified
`cargo test -p nyxid-cli --test wizard_bundle_freshness` passes).

Caught by the independent breaking-change audit on PR #776.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(devices): clean up pending_auth on device-code timeout/cancel

CLI + dashboard device-code flows now DELETE the placeholder via
?only_if_pending=true on every abort path (expiry+declined renewal,
denial, network errors, ctrl+C, dashboard Back/Cancel, dialog
unmount), so `nyxid service list` no longer accumulates leftover
services from abandoned attempts. Also adds a redrawn countdown
timer and a [Y/n] renewal prompt in the CLI's TTY mode; non-TTY
bails on first expiry without prompting.

Closes #706.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(devices): wizard countdown + cleanup + responsive favicon

Ports the device-code countdown + on-expiry/denied cleanup from the
terminal-only and dashboard paths to the cli-wizard's DeviceCodeFlow
(frontend/src/components/cli-wizard/auth-flows.tsx), which is the
default path most users hit. The wizard now reads init.expires_in for
its client-side deadline (was hardcoded 10min), shows an "Expires in
MM:SS" chip beside the CODE label, and calls releaseServerState() on
expired/denied so the placeholder doesn't linger if the user walks
away. The "Request a new code" button continues to work via existing
startSession() re-entry.

CLI follow-ups in the same change:
- Decouple countdown redraw from poll cadence: the inner loop now
  ticks every 1s for visible MM:SS movement while the HTTP poll is
  throttled to the provider's `interval` (5s+).
- Add NYXID_DEVICE_CODE_DEADLINE_SECS env var that clamps the
  client-side deadline in both the terminal flow and (via a
  forwarded ?expires_in_override query param) the wizard, so QA can
  exercise expiry/renewal paths in seconds instead of waiting ~15min
  for a real provider expiry.

Wizard now ships a responsive SVG favicon (nyxid-app-icon.svg)
matching the dashboard's <link rel="icon"> — previously the wizard
served only the static .ico fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Seed `google-cloud` provider with `access_type=offline` + `prompt=consent`
  extra_auth_params so Google returns a refresh_token. Without these the
  user gets a 1h access token and proxy calls fail when it expires.
  Matches the existing `google` provider seed.
- Document the operator setup story in skills/nyxid/references/services.md:
  platform-default vs BYO OAuth client, the required redirect URI
  (`<BASE_URL>/api/v1/providers/callback`), and the `--oauth-client-id` /
  `--oauth-client-secret-env` flags. Without this, fresh deployments hit
  `--oauth` failures because the seeded provider has no client credentials.
feat(catalog): switch GCP to generic api-google-cloud OAuth service (#778)
…711) (#798)

When the wizard server's request to the NyxID backend timed out, the
proxy returned a body the shared frontend ApiError parser couldn't
extract a `message` from, so the confirm panel's catch block silently
set an empty error and the button just re-enabled — the user couldn't
tell the request had failed.

Backend (cli/src/wizard/server.rs):
- New `proxy_error_response` helper emits ApiErrorResponse-shaped JSON
  (`error` + `error_code` + `message` + `detail`) with `Content-Type:
  application/json` on every proxy-error branch.
- Distinguish timeout (504 + "upstream_timeout") from connection
  failure (502 + "upstream_unreachable") via `reqwest::Error::is_timeout()`.
- CLI stderr prints a user-facing line on each branch instead of only
  the low-level transport error.

Frontend:
- New `UpstreamErrorBanner` — dismissible top-of-page banner with
  timeout/unreachable variants, mounted in the wizard shell.
- Fetch shim (`client.ts`) sniffs upstream-error response bodies and
  dispatches a window event, so every flow that routes through
  `/api/v1/*` gets the banner without per-panel wiring.
- `WizardApp` subscribes via `onUpstreamError`, gates the banner to
  the editable confirm phase, and clears on user dismiss.
- AI-key confirm panel falls back to a default message when
  `ApiError.message` is empty — defense-in-depth against future shape
  drift.

Tests:
- 8 new vitest cases for `shouldShowUpstreamBanner` phase-gating.
- Existing 48 Rust wizard-server tests and 576 frontend tests still pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous WARN claimed OAuth flows would fail until an admin configured
platform-level credentials. That's wrong: with the seeded credential_mode=user,
users can connect via BYO `--oauth-client-id` / `--oauth-client-secret-env`
on `nyxid service add`. The platform default is just one of two valid paths.

Downgrade to INFO and rewrite the message to surface both options.
The #778 cleanup removed the seed code for the gcp-cloud-billing /
gcp-bigquery-billing catalog services and their gcp-billing / gcp-bigquery
providers, but pre-existing rows from earlier deployments stay in MongoDB
and still surface in the admin catalog UI -- with the now-defunct
`gcp_service_account` auth_method that the proxy can no longer satisfy.

Add a one-shot startup cleanup that deletes:

- DownstreamService rows on the two removed service slugs
- ProviderConfig rows on the two removed provider slugs
- ServiceProviderRequirement rows referencing either side of the above

Per-user data (UserService, UserProviderToken) is preserved -- the
existing warning continues to nudge affected users to re-add via the new
`api-google-cloud --oauth` flow.
Expand the #778 startup cleanup to remove user-side rows that were auto-
provisioned from the now-defunct `gcp-cloud-billing` / `gcp-bigquery-billing`
catalog entries. Without this, surviving UserService rows reference
deleted DownstreamService rows and carry credentials for the gone
`gcp_service_account` auth method — dead data the proxy can never use,
and a credential leak waiting to happen.

Cascade order (idempotent — no-op once clean):

  AgentServiceBinding (by user_service_id)
  UserService         (by slug IN [gcp-cloud-billing, gcp-bigquery-billing])
  UserEndpoint        (by endpoint_id, only if not referenced by another svc)
  UserApiKey          (by api_key_id,   only if not referenced by another svc)
  UserProviderToken   (by provider_config_id IN [gcp-billing, gcp-bigquery])
  UserProviderCreds   (same)
  ServiceProviderRequirement (either side)
  DownstreamService / ProviderConfig

The orphan-check on UserEndpoint / UserApiKey matches the existing pattern
in `unified_key_service::cleanup_orphan_endpoints_for_user` so a key shared
with a non-GCP service isn't deleted out from under it.

Affected users re-add via `api-google-cloud --oauth --endpoint-url ...`.
…p-warning

fix(catalog): #778 follow-ups — drop stale GCP rows, downgrade warning
…789)

Issue #651 stored an actionable account-mismatch message on the failed
credential, but the two surfaces meant to display it were both
intercepting it on the way to the user:

- frontend/src/router.tsx: the /providers parent route's beforeLoad
  redirects non-admin users to /keys. That guard fires before the
  /providers/callback child route can render, so the browser-account
  user (typically not the CLI admin) lands on the AI Services list with
  the error query string discarded. Let /providers/callback past the
  guard so ProvidersCallbackPage renders status=error&message=... for
  any signed-in user.

- cli/src/commands/service.rs: wait_for_authorized_key collapses every
  non-"active" status into a single "OAuth flow finished with unexpected
  key status: <other>" bail, dropping the credential's error_message
  field on the floor. Split "failed" out of the catch-all and bail with
  the stored error_message verbatim, falling back to a generic string
  only if it's empty.

No backend changes -- the message already exists on the credential.
This just connects the existing data to the surfaces the user is
actually looking at.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validate the saved session before any command does user-visible work
(browser wizard, SSH socket, proxy request) so a dead/expired session
prompts for re-login (interactive) or errors cleanly (headless) instead
of opening a wizard/command that then 401s with "please log in".

- auth::ensure_session: skip for explicit --access-token/NYXID_ACCESS_TOKEN;
  local JWT exp check (60s skew, zero network on the happy path); one silent
  /auth/refresh on expiry; on a dead session prompt-to-relogin when
  interactive (then exit 0 asking to re-run) or a clean error when headless.
  No auto-login.
- ApiClient::from_auth_checked wraps the gate + client build; wired into
  session-authed commands, all 8 wizard entry points, and the SSH arms
  (gated before the raw WebSocket token is read).
- Consolidate the three /auth/refresh implementations into one canonical
  auth::exchange_refresh_token (preflight, ApiClient in-flight retry, wizard
  server). 429/408 treated as retryable, not session-dead.

Refs #801

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings dev up to date with main (6 commits since PR #799):
- feat(cli): preflight session validation before authed commands (#803)
- fix(oauth): surface account-mismatch error to browser and CLI (#695) (#789)
- fix(catalog): drop stale GCP service-account rows + cascade-delete (#800)
- fix(provider): downgrade google-cloud startup warning to info

Merge resolves to main's tree exactly; dev has no unique content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ctkm-aelf
ctkm-aelf merged commit ae11b60 into dev May 20, 2026
12 checks passed
@ctkm-aelf
ctkm-aelf deleted the chore/sync-dev-with-main-20260520 branch May 20, 2026 09:50
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.

1 participant