diff --git a/.gitignore b/.gitignore index 1a63a276f..62cb50420 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,10 @@ bin/ .gstack/ supabase/.temp/ .claude/skills/ -admin/dist/ +# admin/dist/ is the React SPA bundle. CLAUDE.md says it's committed for +# self-contained binaries (the bun --compile path embeds it via +# `import path from 'admin/dist/index.html' with { type: 'file' }`). +# Build via: cd admin && bun install && bun run build. admin/node_modules/ .idea eval/reports/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9001d5031..1d7c84297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,107 @@ All notable changes to GBrain will be documented in this file. +## [0.26.2] - 2026-05-03 + +## **MCP fix-wave: every postgres-as-string OAuth bug, killed at the boundary.** +## **Bigger guarantees: `revoke-client` lands as a real CLI, NULL expires_at is treated as expired, corrupt rows fail loud at the row-read boundary instead of skating past validation.** + +`gbrain serve --http` now does the right thing on every postgres-driver-as-string edge case the v0.26.1 hot-fix didn't reach. The same bug class that broke `client_credentials` token validation in production (postgres.js with `prepare: false` returns BIGINT columns as strings, and the MCP SDK's bearerAuth checks `typeof === 'number'`) hides at four other read sites in `src/core/oauth-provider.ts`. Two of those flow into the RFC 7591 §3.2.1 Dynamic Client Registration response, where strict OAuth clients reject string timestamps and the registration silently fails. v0.26.2 closes the bug class with a single named helper at the boundary. + +The shape changed during eng + outside-voice review. The first draft normalized rows with inline `Number(...)` calls, but a `Number('foo') → NaN` slipping through is fail-OPEN, not fail-closed: `NaN < now` is `false`, so the expired-token branch is skipped and the SDK gets `expiresAt: NaN` as if the token were valid. Codex flagged this. The shipped helper, `coerceTimestamp()`, throws on non-finite input — corrupt rows fail loud at the boundary instead of riding through token validation. + +Plus: `gbrain auth revoke-client ` lands as a first-class CLI subcommand. Schema-level `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token and authorization code in a single atomic transaction. The matching v0.26.1 E2E test had been calling this subcommand all along — silently failing because the subcommand didn't exist. v0.26.2 makes the cleanup actually work. + +### The numbers that matter + +5 string-vs-number sites identified in the original v0.26.1 audit; 5 fixed in v0.26.2. 4 new tests covering surfaces v0.26.1 didn't reach: real DCR `/register` HTTP-level response shape, real CLI subprocess invocation of `revoke-client`, NULL `expires_at` semantics, cascade-delete contract. + +| Metric | BEFORE v0.26.2 | AFTER v0.26.2 | Δ | +|---|---|---|---| +| Sites where postgres-as-string can break OAuth | 4 latent (1 fixed in v0.26.1) | 0 | bug class closed | +| `Number(...)` on corrupt row | flows through as NaN (fail-OPEN) | helper throws (fail-CLOSED) | loud failure | +| `gbrain auth revoke-client` | doesn't exist | first-class CLI subcommand | +1 | +| E2E afterAll cleanup | silently failing | actually deletes the test client | reliable | +| DCR `/register` response timestamps | strings under `prepare: false` | RFC 7591 §3.2.1 numbers | spec-compliant | + +### What this means for operators + +Strict OAuth clients (Claude Code, Cursor) connecting via `gbrain serve --http` get spec-compliant `client_id_issued_at` numbers in their DCR responses. Operators get a real `revoke-client` subcommand and CASCADE-driven token purge. CI runs no longer leak orphan `gbrain_cl_*` rows on every E2E pass. Run `gbrain upgrade`. No schema migration. No manual step. + +### For contributors + +The boundary helper `coerceTimestamp` is intentionally module-private to `src/core/oauth-provider.ts` and not promoted to `src/core/utils.ts`. Codex review flagged repo-wide BIGINT precision-loss risk for a generic helper; the OAuth surface is bounded and well-understood, the rest of the repo isn't. Promote later if the pattern recurs. + +### Known caveats + +Hard-deleting a client orphans its entries in `mcp_request_log` (the table stores `token_name` TEXT with no FK). The admin UI's request-log view will show those entries with the literal token_name and no client correlation. Acceptable for a fix-wave; v0.27 can add a `[revoked]` badge or `LEFT JOIN`-aware rendering if forensics needs grow. + +## To take advantage of v0.26.2 + +`gbrain upgrade` is sufficient. No schema migration. No manual step. + +```bash +gbrain upgrade +gbrain --version # should print 0.26.2 +``` + +If you operate `gbrain serve --http` and have OAuth clients registered, no client-side action is needed. Existing tokens keep working. Rolling token rotation continues to work. The new `gbrain auth revoke-client ` subcommand is available for cleanup. + +### Itemized changes + +#### OAuth bug-class fixes +- **`coerceTimestamp()` boundary helper** in `src/core/oauth-provider.ts`. Throws on non-finite input (NaN/Infinity); returns undefined for SQL NULL so callers decide NULL semantics explicitly. Doc comment names the three load-bearing pieces: postgres `prepare: false` BIGINT-as-string behavior, MCP SDK's `typeof === 'number'` bearerAuth check, RFC 7591 §3.2.1 JSON-number requirement. +- **5 call sites refactored** to use the helper: + - `getClient` (L112, L113): `client_id_issued_at` and `client_secret_expires_at` now flow through the helper, so DCR `/register` responses are RFC-compliant numbers. + - `exchangeRefreshToken` (L274): NULL `expires_at` is treated as expired (fail-closed). Schema permits NULL on `oauth_tokens.expires_at`; corrupt rows can no longer ride past validation. + - `verifyAccessToken` (L296, L303): same NULL-as-expired contract for access tokens; the SDK's bearerAuth gets a guaranteed `typeof === 'number'` value. +- **Removed inline `Number(...)` from L303** introduced in v0.26.1; replaced with the helper-narrowed value from the L296 guard for consistency. Behavior unchanged. + +#### New CLI subcommand +- **`gbrain auth revoke-client `** lands in `src/commands/auth.ts`. Atomic `DELETE...RETURNING` on `oauth_clients`, FK CASCADE purges `oauth_tokens` and `oauth_codes`. Prints client name + cascade confirmation. `process.exit(1)` on no-such-client (idempotent: re-running on the same id produces the same exit-1 message). +- Help text + router case wired alongside `register-client`. + +#### Tests +- `test/oauth.test.ts`: 5 unit cases for `coerceTimestamp` (null/undefined/string/number/throw-on-NaN), NULL-`expires_at`-as-expired contract test, cascade-delete contract test. +- `test/e2e/serve-http-oauth.test.ts`: real DCR `/register` HTTP-level response-shape test (asserts `typeof body.client_id_issued_at === 'number'` over the wire, not just internal-store shape); real CLI subprocess test for `revoke-client` (registers → mints token → revokes via execSync → asserts token rejected at `/mcp` → asserts re-run exits 1). +- E2E `afterAll` cleanup: now guarded on `clientId` (won't throw if `beforeAll` failed before registration); cleanup errors surface to stderr without throwing so real test failures aren't masked. Tracks DCR-registered clients alongside the manual one. +- Server fixture: `--enable-dcr` added so `/register` is reachable in the DCR test. + +#### Mechanics +- `VERSION` → `0.26.2`. `package.json` → `0.26.2`. `bun.lock` refreshed. + +### Credits + +This branch was driven by an audit of PR #577 (v0.26.1). Codex independent review surfaced 5 factual errors and 7 design gaps the in-house eng review had cleared. The shipped scope is tighter and more honest than the original D1 plan — the outside voice was the load-bearing input. + +## [0.26.1] - 2026-05-03 + +## **MCP bearer-auth hot-fix: `client_credentials` tokens stop being rejected at `/mcp`.** + +A three-bug fix-wave landed on master as PR #577 to unblock production OAuth connections. Every token minted via `client_credentials` was being rejected at `/mcp` with `HTTP 401 {"error":"invalid_token","error_description":"Token has no expiration time"}`. Token issuance worked; validation failed because the postgres-js driver with `prepare: false` returns BIGINT columns as strings and the MCP SDK's bearerAuth middleware checks `typeof authInfo.expiresAt === 'number'`. + +Found in production connecting Claude Code through Caddy/Tailscale to `gbrain serve --http`. + +### What shipped + +- **`Number(row.expires_at)` cast** in `verifyAccessToken` (`src/core/oauth-provider.ts:303`) so the SDK gets a JS number, not a postgres string. +- **OAuth metadata interceptor middleware** in `src/commands/serve-http.ts:164-175`. The MCP SDK hardcodes `grant_types_supported: ['authorization_code', 'refresh_token']` in its `.well-known/oauth-authorization-server` response. The middleware patches `res.json` to append `client_credentials` so RFC-conformant clients (Claude Code, Cursor) auto-discover the flow. +- **Express 5 compat fixes** in `src/commands/serve-http.ts`: + - `app.set('trust proxy', 'loopback')` so reverse-proxy deployments (Caddy on localhost, Tailscale) don't crash `express-rate-limit` with `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR`. Restricts proxy trust to localhost only — does NOT trust arbitrary `X-Forwarded-For`. + - `/admin/{*path}` (Express 5 named-wildcard syntax) instead of the bare `/admin/*` Express 5 dropped. + +### Tests + +50 cases / 201 assertions including a real-Postgres E2E (`test/e2e/serve-http-oauth.test.ts`) that spawns a subprocess server, registers an OAuth client via the CLI, mints tokens via client_credentials, and exercises the full MCP JSON-RPC pipeline end-to-end. + +### Process note + +PR #577 shipped its three fixes but did not bump `VERSION`, `package.json`, or `CHANGELOG.md`. v0.26.2 retroactively writes this v0.26.1 entry so the changelog matches the commit history. The /ship workflow's version idempotency check (Step 12) will catch drifts like this in the future. + +### Credits + +Co-authored by Wintermute. Found in production. Three bugs, 22 lines, real fix. + ## [0.26.0] - 2026-04-25 ## **Multi-agent MCP is real. OAuth 2.1, HTTP server, React admin dashboard. Ship once, every AI client connects.** diff --git a/CLAUDE.md b/CLAUDE.md index e1486e535..6e33b8f7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,9 +129,9 @@ strict behavior when unset. - `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP transports. Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1/F2/F3 drift bugs in the original v0.22.5 HTTP transport. - `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter. `buildDefaultLimiters()` returns the two-bucket pipeline: pre-auth IP (30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap bounds memory under attacker-controlled key growth. - `src/commands/serve-http.ts` (v0.26.0) — Express 5 HTTP MCP server with OAuth 2.1, admin dashboard, and SSE live activity feed. Started via `gbrain serve --http [--port N] [--token-ttl N] [--enable-dcr] [--public-url URL]`. Supersedes the v0.22.7 `src/mcp/http-transport.ts` simple bearer-auth path. Combines MCP SDK's `mcpAuthRouter` (authorize / token / register / revoke endpoints), a custom `client_credentials` handler (SDK's token endpoint throws `UnsupportedGrantTypeError` for CC; the custom handler runs BEFORE the router and falls through for `auth_code` / `refresh_token`), `requireBearerAuth` middleware for `/mcp` with scope enforcement before op dispatch, `localOnly` rejection, and `express-rate-limit` at 50 req / 15 min on `/token`. Serves the built admin SPA from `admin/dist/` with SPA fallback. `/admin/events` SSE endpoint broadcasts every MCP request to connected admin browsers. `cookie-parser` middleware wired (Express 5 has no built-in). Startup logging prints port, engine, configured issuer URL (honors `--public-url`), registered-client count, DCR status, and admin bootstrap token. -- `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch. +- `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch. **v0.26.2:** module-private `coerceTimestamp()` boundary helper at the top of the file normalizes postgres-driver-as-string BIGINT columns to JS numbers at every read site (5 call sites: `getClient` L112+L113 for DCR `/register` RFC 7591 §3.2.1 numeric timestamps, `exchangeRefreshToken` L274 + `verifyAccessToken` L296+L303 for the SDK's `typeof === 'number'` bearerAuth check). Throws on non-finite input (NaN/Infinity) so corrupt rows fail loud at the boundary instead of riding through as `expiresAt: NaN`; returns undefined for SQL NULL so callers decide NULL semantics explicitly (refresh + access token paths treat NULL as expired). Helper intentionally NOT promoted to `src/core/utils.ts` — codex review flagged repo-wide BIGINT precision-loss risk for a generic helper. - `admin/` (v0.26.0) — React 19 + Vite + TypeScript admin SPA embedded in the binary via `admin/dist/` served by `serve-http.ts`. 7 screens: Login (bootstrap token → session cookie), Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes + grant type selector), Credentials reveal (full-screen modal with Copy + Download JSON + yellow one-time-only warning), Request Log (filterable paginated), Agent Detail drawer (Details / Activity / Config Export tabs + Revoke). Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges. HTTP-only SameSite=Strict cookie auth. 65KB gzip. Build: `cd admin && bun install && bun run build`; output at `admin/dist/` is committed for self-contained binaries. -- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) for OAuth 2.1 client registration. Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. +- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) and `gbrain auth revoke-client ` (v0.26.2) for OAuth 2.1 client lifecycle. `revoke-client` runs an atomic `DELETE...RETURNING` on `oauth_clients`; FK `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token + authorization code in a single transaction. `process.exit(1)` on no-such-client (idempotent — re-running on the same id produces the same exit-1 message). Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. - `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally. - `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry ` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed. - `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent. @@ -356,7 +356,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac `test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present), `test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics), `test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases), -`test/oauth.test.ts` (v0.26.0 OAuth 2.1 provider — 27 cases: register, getClient, `client_credentials` grant exchange, `authorization_code` flow with PKCE challenge / verifier, refresh token rotation, `verifyAccessToken` with both OAuth + legacy `access_tokens` fallback, `revokeToken`, `sweepExpiredTokens`, and a contract test asserting `scope` + `localOnly` annotations are set correctly on all 30 operations), +`test/oauth.test.ts` (v0.26.0 OAuth 2.1 provider — 27 cases: register, getClient, `client_credentials` grant exchange, `authorization_code` flow with PKCE challenge / verifier, refresh token rotation, `verifyAccessToken` with both OAuth + legacy `access_tokens` fallback, `revokeToken`, `sweepExpiredTokens`, and a contract test asserting `scope` + `localOnly` annotations are set correctly on all 30 operations; **v0.26.2** adds 5 `coerceTimestamp` unit cases (null/undefined/string/number/throw-on-NaN), NULL-`expires_at`-as-expired contract tests for both refresh + access token paths, and a cascade-delete contract test asserting `revoke-client` purges `oauth_tokens` + `oauth_codes` rows via FK CASCADE), `test/check-resolvable-cli.test.ts` (v0.19 CLI wrapper: exit codes, JSON envelope shape, AGENTS.md fallback chain), `test/regression-v0_16_4.test.ts` (findRepoRoot regression guard — hermetic startDir parameterization), `test/filing-audit.test.ts` (v0.19 Check 6: `writes_pages` / `writes_to` frontmatter, filing-rules JSON validation), @@ -383,6 +383,7 @@ E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_U - `test/e2e/engine-parity.test.ts` (v0.22.0) — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector`. Codex flagged that Postgres ranks pages then picks best chunk while PGLite returns chunks directly — without parity coverage the source-boost fix could pass on PGLite and fail on Postgres. Skips gracefully when `DATABASE_URL` is unset. - `test/e2e/postgres-bootstrap.test.ts` (v0.22.6.1) — exercises `PostgresEngine.initSchema()` directly against a fresh real Postgres database. Asserts the bootstrap path is no-op on fresh installs and that SCHEMA_SQL replays cleanly through the engine path (not via the standalone `db.initSchema` from `src/core/db.ts`, which would have produced false-positive coverage). Codex caught the E2E-shape gap during plan review. - `test/e2e/http-transport.test.ts` (v0.22.7) — 8 cases against real Postgres covering `gbrain serve --http` end-to-end: bearer auth round-trip, `last_used_at` SQL-level debounce semantics, `mcp_request_log` row insertion on success and auth_failed paths, `/health` DB-down → 503 (DB-probing health check), and the F1+F2+F3 dispatch round-trip with a real operation. Skips gracefully when `DATABASE_URL` is unset. +- `test/e2e/serve-http-oauth.test.ts` (v0.26.0, expanded v0.26.2) — real-Postgres E2E against `gbrain serve --http` with full OAuth 2.1. Spawns a subprocess server, registers a client via the CLI, mints `client_credentials` tokens, exercises the `/mcp` JSON-RPC pipeline. **v0.26.2 adds:** real DCR `/register` HTTP-level response-shape test (asserts `typeof body.client_id_issued_at === 'number'` over the wire — RFC 7591 §3.2.1 spec compliance, not just internal-store shape); real CLI subprocess test for `revoke-client` (registers → mints token → revokes via `execSync` → asserts token rejected at `/mcp` → asserts re-run exits 1); server fixture flips on `--enable-dcr` so `/register` is reachable. **bun execSync env-inheritance fix:** bun's `execSync` does NOT inherit env mutations done via `process.env.X = ...`, only OS-level env from before bun started. helpers.ts loads `.env.testing` and sets `DATABASE_URL` via `process.env` mutation, which is invisible to subprocesses unless `env: { ...process.env }` is passed explicitly — every subprocess call in this file passes `env: { ...process.env }` for that reason. Reference fix for the next maintainer hitting the same failure mode in sibling sync/cycle/dream/claw-test E2Es. `afterAll` cleanup is guarded on `clientId` (won't throw if `beforeAll` failed before registration); cleanup errors surface to stderr without throwing so real test failures aren't masked. Tracks DCR-registered clients alongside the manual one. Skips gracefully when `DATABASE_URL` is unset. - `test/e2e/sync-parallel.test.ts` (v0.22.13 PR #490) — DATABASE_URL-gated. T2: 60-file Postgres sync at concurrency=4 imports all + no connection leak (probes `pg_stat_activity` before/after to confirm worker engines disconnected). P4: 120-file serial-vs-parallel benchmark prints `SYNC_PARALLEL_BENCH N files | serial=Xms | parallel(4)=Yms | speedup=Zx` for CHANGELOG quoting. Asserts parallel ≤ serial × 1.5 (CI-noise tolerant; not a strict speedup gate). - Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI - If `.env.testing` doesn't exist in this directory, check sibling worktrees for one: @@ -494,6 +495,40 @@ For single long-running queries, use `startHeartbeat(reporter, note)` with a try/finally to guarantee cleanup. Never call `process.stdout.write('\r...')` in bulk paths, the CI guard will fail the build. +## Capturing test output (NEVER pipe through `tail` / `head`) + +**Iron rule:** when running `bun test`, `bun run test:e2e`, `bun run typecheck`, +or any other test/check command, redirect to a file FIRST, then `tail` the file +separately: + +```bash +# RIGHT — full output preserved, real exit code visible +bun test > /tmp/ship_units.txt 2>&1 +echo "EXIT=$?" +tail -50 /tmp/ship_units.txt +grep -E '(fail\)|✗|error:' /tmp/ship_units.txt | head -30 +``` + +```bash +# WRONG — exit code is `tail`'s (always 0), failures truncated, ship gates fail open +bun test 2>&1 | tail -10 +``` + +The pipe form silently breaks /ship Step T1 (test failure ownership triage) and +the test verification gate (Step 16) because: +- `$?` after a pipe is the LAST command's exit code (`tail` → 0), not bun's +- bun prints failure details before the summary line, so `tail -N` drops them +- Step T1 needs the full failure list to classify in-branch vs pre-existing + +This bit us during v0.26.2 ship: `bun test 2>&1 | tail -10` reported "3911 pass / 23 fail" +but no failure details survived, forcing a 23-minute re-run to triage. + +Apply the same pattern to any long-running command whose exit code matters: +`bun run typecheck`, `bun run ci:local`, migration runs, eval suites, etc. +For background tasks (`run_in_background: true`), the harness captures the exit +file separately — use it via the bg task's `.exit` file, not the streamed +output. + ## Build `bun build --compile --outfile bin/gbrain src/cli.ts` diff --git a/README.md b/README.md index 33ff818c0..235114e5e 100644 --- a/README.md +++ b/README.md @@ -734,6 +734,8 @@ ADMIN gbrain auth register-client Register an OAuth 2.1 client --grant-types client_credentials,authorization_code --scopes "read write admin" + gbrain auth revoke-client Revoke an OAuth 2.1 client (cascade purges + active tokens + auth codes via FK CASCADE) # OAuth 2.1 clients can also be registered from the /admin dashboard or # programmatically via oauthProvider.registerClientManual() for host-repo wrappers. gbrain integrations Integration recipe dashboard diff --git a/TODOS.md b/TODOS.md index b3ba78bfd..eb49f295c 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,45 @@ # TODOS +## test infra (v0.26.2 follow-up — pre-existing failures triage) + +### Fix 22 pre-existing test failures unrelated to OAuth +**Priority:** P0 + +**What:** A `bun test` run on top of master at v0.26.2 surfaces 22 pre-existing failures across these suites — none touch v0.26.2's diff (oauth-provider.ts, auth.ts, oauth tests). They reproduce on a clean checkout against master: + +- 12 cases in `test/e2e/sync.test.ts` (Git-to-DB Sync Pipeline) — `result.status === 'first_sync'` vs actual `'synced'` state-machine drift; same root cause across all 12. +- 3 cases in `test/e2e/multi-source.test.ts` (cascade delete + 2 sync routing) — performSync sourceId/local_path resolution. +- `test/e2e/sync-parallel.test.ts` (60-file Postgres concurrency=4) — connection-leak probe regression. +- `test/e2e/sync.test.ts` `--skip-failed` structured summary loop (v0.22.12 #500). +- `test/e2e/dream.test.ts` (no --dry-run syncs pages) — runCycle DB write path. +- `test/e2e/cycle.test.ts` (live cycle + chunks + lock cleanup). +- `test/e2e/doctor.test.ts` (gbrain doctor exits 0 on healthy DB) — possibly related to v0.26.2 schema changes since CHANGELOG mentions extension of doctor checks. +- `test/brain-registry.test.ts` (empty/null/undefined id routes to host) — unrelated to OAuth surface. +- `test/e2e/claw-test.test.ts` (fresh-install scripted scenario) — needs investigation; took 3.9s and reported "produces zero error/blocker friction" failure. + +**Why:** These failures pre-date v0.26.2 (CHANGELOG already documents "18 pre-existing master timeouts" from v0.26.0 merge). v0.26.2 brings the count to 22, suggesting a 4-test drift on master between v0.26.0 ship and now. Fixing inside v0.26.2 would balloon scope from a 6-file OAuth fix-wave to a 30+ file test-infra repair. The fix-wave deserves its own PR with focused triage. + +**Likely root causes worth investigating:** +- **bun execSync env inheritance** (already discovered + fixed in test/e2e/serve-http-oauth.test.ts during v0.26.2): bun's `execSync` does NOT inherit env mutations done via `process.env.X = ...`, only OS-level env from before bun started. helpers.ts loads `.env.testing` and sets `DATABASE_URL` via `process.env` mutation, which is invisible to subprocesses unless `env: { ...process.env }` is passed explicitly. Several of the failing E2E tests (sync, cycle, dream, claw-test) spawn subprocesses via execSync — likely the same bug. +- **Test ordering / DB state pollution**: full-suite runs in bun test happen in a deterministic order; isolated runs of these test files may pass while suite runs fail. Could indicate beforeAll/afterAll cleanup gaps. +- **Schema drift**: doctor/multi-source tests may rely on specific schema state that v0.26 OAuth tables changed. + +**Pros:** +- Separating from v0.26.2 keeps the OAuth ship focused and auditable; the 22 failures aren't blocking real-world OAuth functionality. +- The execSync env-inheritance pattern is now documented in test/e2e/serve-http-oauth.test.ts as a reference fix for the next maintainer. +- Unblocks v0.26.2 ship while preserving the failure inventory for the follow-up. + +**Cons:** +- 22 failing tests on master is real test-infra debt. +- Some may be load-bearing (sync pipeline failures could mask real regressions in `performSync`). +- `bun run ci:local` (full E2E gate) won't pass cleanly until these are addressed. + +**Context:** Discovered during v0.26.2 ship audit. Reproduce with `bun test 2>&1 | grep "^(fail)"` after copying `.env.testing` from a sibling worktree (port 5435 test DB running). The 17/17 OAuth E2E suite passes in isolation AND in full-suite after the env-inheritance fix landed. + +**Effort:** L (human ~4-8h; CC ~30-60min once env-inheritance fix is applied across all tests). + +**Depends on / blocked by:** None — independent of v0.26.2. + ## ci-local-mirror ### CI-skip artifact + signature for stages 1+2 follow-up diff --git a/VERSION b/VERSION index 4e8f395fa..894542aa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.26.0 +0.26.2 diff --git a/admin/dist/assets/index-0QYnbXj9.css b/admin/dist/assets/index-0QYnbXj9.css new file mode 100644 index 000000000..86922c05b --- /dev/null +++ b/admin/dist/assets/index-0QYnbXj9.css @@ -0,0 +1 @@ +:root{--bg-primary: #0a0a0f;--bg-secondary: #14141f;--bg-tertiary: #1e1e2e;--text-primary: #e0e0e0;--text-secondary: #888;--text-muted: #555;--accent: #3b82f6;--success: #22c55e;--warning: #f59e0b;--error: #ef4444;--font-mono: "JetBrains Mono", monospace;--font-sans: "Inter", system-ui, sans-serif}*{margin:0;padding:0;box-sizing:border-box}body{font-family:var(--font-sans);background:var(--bg-primary);color:var(--text-primary);font-size:14px;line-height:1.5}.app{display:flex;min-height:100vh}.sidebar{width:200px;background:var(--bg-secondary);border-right:1px solid #1e1e2e;padding:16px 0;flex-shrink:0;display:flex;flex-direction:column}.sidebar-logo{font-size:18px;font-weight:600;padding:0 16px 24px;color:var(--text-primary)}.sidebar-nav{display:flex;flex-direction:column;gap:2px}.nav-item{display:flex;align-items:center;gap:8px;padding:8px 16px;color:var(--text-secondary);text-decoration:none;font-size:13px;cursor:pointer;border-left:3px solid transparent;transition:all .15s}.nav-item:hover{background:var(--bg-tertiary);color:var(--text-primary)}.nav-item.active{border-left-color:var(--accent);background:var(--bg-tertiary);color:var(--text-primary)}.main{flex:1;padding:24px 32px;overflow-y:auto}.page-title{font-size:24px;font-weight:600;margin-bottom:24px}.metrics{display:flex;gap:16px;margin-bottom:24px}.metric{background:var(--bg-secondary);padding:16px 20px;border-radius:6px;min-width:140px}.metric-value{font-family:var(--font-mono);font-size:28px;font-weight:500}.metric-label{font-size:12px;color:var(--text-secondary);margin-top:4px}table{width:100%;border-collapse:collapse}th{text-align:left;font-size:11px;text-transform:uppercase;color:var(--text-muted);padding:8px 12px;font-weight:500;letter-spacing:.5px}td{padding:10px 12px;font-size:13px;border-top:1px solid #1a1a2a}tr:hover td{background:var(--bg-tertiary)}.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500}.badge-read{background:#3b82f626;color:var(--accent)}.badge-write{background:#f59e0b26;color:var(--warning)}.badge-admin{background:#ef444426;color:var(--error)}.badge-success{background:#22c55e26;color:var(--success)}.badge-error{background:#ef444426;color:var(--error)}.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}.status-active{background:var(--success)}.status-warning{background:var(--warning)}.status-inactive{background:var(--text-muted)}.btn{padding:8px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all .15s}.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:#2563eb}.btn-secondary{background:transparent;color:var(--text-secondary);border:1px solid #333}.btn-secondary:hover{border-color:var(--text-secondary);color:var(--text-primary)}.btn-danger{background:transparent;color:var(--error);border:1px solid var(--error)}.btn-danger:hover{background:#ef44441a}input,select{background:var(--bg-primary);border:1px solid #333;color:var(--text-primary);padding:8px 12px;border-radius:6px;font-size:13px;font-family:var(--font-sans);width:100%}input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px #3b82f633}input::placeholder{color:var(--text-muted)}label{display:block;font-size:13px;font-weight:500;margin-bottom:6px}.modal-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#000000b3;display:flex;align-items:center;justify-content:center;z-index:100}.modal{background:var(--bg-secondary);border-radius:8px;padding:24px;min-width:420px;max-width:520px}.modal-title{font-size:18px;font-weight:600;margin-bottom:20px}.drawer-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;z-index:90}.drawer{position:fixed;right:0;top:0;bottom:0;width:420px;background:var(--bg-secondary);border-left:1px solid var(--accent);padding:24px;z-index:91;overflow-y:auto}.drawer-close{position:absolute;top:16px;right:16px;background:none;border:none;color:var(--text-muted);font-size:18px;cursor:pointer}.section-title{font-size:11px;text-transform:uppercase;color:var(--text-muted);letter-spacing:.5px;margin:20px 0 12px;font-weight:500}.health-panel{background:var(--bg-secondary);border-radius:6px;padding:16px}.health-row{display:flex;justify-content:space-between;padding:6px 0;font-size:13px}.code-block{background:var(--bg-primary);border-radius:6px;padding:12px;font-family:var(--font-mono);font-size:12px;overflow-x:auto;position:relative}.code-block .copy-btn{position:absolute;top:8px;right:8px;background:var(--accent);color:#fff;border:none;padding:4px 10px;border-radius:4px;font-size:11px;cursor:pointer}.feed{max-height:400px;overflow-y:auto}.feed-empty{color:var(--text-muted);text-align:center;padding:32px;font-size:13px}.sparkline{display:inline-block;vertical-align:middle}.filter-bar{display:flex;gap:12px;margin-bottom:16px;align-items:center}.filter-bar select{width:auto;min-width:140px}.pagination{display:flex;justify-content:space-between;align-items:center;padding:12px 0;font-size:13px;color:var(--text-secondary)}.pagination button{background:var(--bg-secondary);border:1px solid #333;color:var(--text-primary);padding:6px 12px;border-radius:4px;cursor:pointer;font-size:12px}.pagination button:disabled{opacity:.3;cursor:default}.warning-bar{background:#f59e0b26;border:1px solid var(--warning);color:var(--warning);padding:10px 16px;border-radius:6px;font-size:13px;margin:12px 0}.checkbox-group{display:flex;gap:16px;flex-wrap:wrap}.checkbox-label{display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer}.tabs{display:flex;gap:0;margin-bottom:12px}.tab{padding:6px 12px;font-size:13px;color:var(--text-secondary);cursor:pointer;border-bottom:2px solid transparent}.tab.active{color:var(--accent);border-bottom-color:var(--accent)}.login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg-primary)}.login-box{text-align:center;width:340px}.login-logo{font-size:32px;font-weight:600;margin-bottom:32px}.login-hint{color:var(--text-muted);font-size:12px;margin-top:12px}.login-error{color:var(--error);font-size:13px;margin-top:8px}.mono{font-family:var(--font-mono);font-size:12px}@media(max-width:768px){.sidebar{display:none}.main{padding:16px}.metrics{flex-wrap:wrap}.drawer{width:100%}} diff --git a/admin/dist/assets/index-BYirrLlW.js b/admin/dist/assets/index-BYirrLlW.js new file mode 100644 index 000000000..4afa887cc --- /dev/null +++ b/admin/dist/assets/index-BYirrLlW.js @@ -0,0 +1,55 @@ +(function(){const X=document.createElement("link").relList;if(X&&X.supports&&X.supports("modulepreload"))return;for(const H of document.querySelectorAll('link[rel="modulepreload"]'))h(H);new MutationObserver(H=>{for(const Y of H)if(Y.type==="childList")for(const x of Y.addedNodes)x.tagName==="LINK"&&x.rel==="modulepreload"&&h(x)}).observe(document,{childList:!0,subtree:!0});function N(H){const Y={};return H.integrity&&(Y.integrity=H.integrity),H.referrerPolicy&&(Y.referrerPolicy=H.referrerPolicy),H.crossOrigin==="use-credentials"?Y.credentials="include":H.crossOrigin==="anonymous"?Y.credentials="omit":Y.credentials="same-origin",Y}function h(H){if(H.ep)return;H.ep=!0;const Y=N(H);fetch(H.href,Y)}})();function Mm(b){return b&&b.__esModule&&Object.prototype.hasOwnProperty.call(b,"default")?b.default:b}var cf={exports:{}},zu={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var rm;function Fy(){if(rm)return zu;rm=1;var b=Symbol.for("react.transitional.element"),X=Symbol.for("react.fragment");function N(h,H,Y){var x=null;if(Y!==void 0&&(x=""+Y),H.key!==void 0&&(x=""+H.key),"key"in H){Y={};for(var P in H)P!=="key"&&(Y[P]=H[P])}else Y=H;return H=Y.ref,{$$typeof:b,type:h,key:x,ref:H!==void 0?H:null,props:Y}}return zu.Fragment=X,zu.jsx=N,zu.jsxs=N,zu}var gm;function Iy(){return gm||(gm=1,cf.exports=Fy()),cf.exports}var o=Iy(),ff={exports:{}},Q={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Sm;function Py(){if(Sm)return Q;Sm=1;var b=Symbol.for("react.transitional.element"),X=Symbol.for("react.portal"),N=Symbol.for("react.fragment"),h=Symbol.for("react.strict_mode"),H=Symbol.for("react.profiler"),Y=Symbol.for("react.consumer"),x=Symbol.for("react.context"),P=Symbol.for("react.forward_ref"),j=Symbol.for("react.suspense"),E=Symbol.for("react.memo"),q=Symbol.for("react.lazy"),M=Symbol.for("react.activity"),K=Symbol.iterator;function Tl(d){return d===null||typeof d!="object"?null:(d=K&&d[K]||d["@@iterator"],typeof d=="function"?d:null)}var Ol={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ml=Object.assign,Dt={};function kl(d,p,O){this.props=d,this.context=p,this.refs=Dt,this.updater=O||Ol}kl.prototype.isReactComponent={},kl.prototype.setState=function(d,p){if(typeof d!="object"&&typeof d!="function"&&d!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,d,p,"setState")},kl.prototype.forceUpdate=function(d){this.updater.enqueueForceUpdate(this,d,"forceUpdate")};function $t(){}$t.prototype=kl.prototype;function ql(d,p,O){this.props=d,this.context=p,this.refs=Dt,this.updater=O||Ol}var it=ql.prototype=new $t;it.constructor=ql,Ml(it,kl.prototype),it.isPureReactComponent=!0;var Et=Array.isArray;function Xl(){}var F={H:null,A:null,T:null,S:null},Ql=Object.prototype.hasOwnProperty;function At(d,p,O){var U=O.ref;return{$$typeof:b,type:d,key:p,ref:U!==void 0?U:null,props:O}}function Za(d,p){return At(d.type,p,d.props)}function pt(d){return typeof d=="object"&&d!==null&&d.$$typeof===b}function Zl(d){var p={"=":"=0",":":"=2"};return"$"+d.replace(/[=:]/g,function(O){return p[O]})}var Ta=/\/+/g;function Nt(d,p){return typeof d=="object"&&d!==null&&d.key!=null?Zl(""+d.key):p.toString(36)}function St(d){switch(d.status){case"fulfilled":return d.value;case"rejected":throw d.reason;default:switch(typeof d.status=="string"?d.then(Xl,Xl):(d.status="pending",d.then(function(p){d.status==="pending"&&(d.status="fulfilled",d.value=p)},function(p){d.status==="pending"&&(d.status="rejected",d.reason=p)})),d.status){case"fulfilled":return d.value;case"rejected":throw d.reason}}throw d}function z(d,p,O,U,Z){var J=typeof d;(J==="undefined"||J==="boolean")&&(d=null);var el=!1;if(d===null)el=!0;else switch(J){case"bigint":case"string":case"number":el=!0;break;case"object":switch(d.$$typeof){case b:case X:el=!0;break;case q:return el=d._init,z(el(d._payload),p,O,U,Z)}}if(el)return Z=Z(d),el=U===""?"."+Nt(d,0):U,Et(Z)?(O="",el!=null&&(O=el.replace(Ta,"$&/")+"/"),z(Z,p,O,"",function(Me){return Me})):Z!=null&&(pt(Z)&&(Z=Za(Z,O+(Z.key==null||d&&d.key===Z.key?"":(""+Z.key).replace(Ta,"$&/")+"/")+el)),p.push(Z)),1;el=0;var Yl=U===""?".":U+":";if(Et(d))for(var Sl=0;Sl>>1,dl=z[cl];if(0>>1;clH(O,G))UH(Z,O)?(z[cl]=Z,z[U]=G,cl=U):(z[cl]=O,z[p]=G,cl=p);else if(UH(Z,G))z[cl]=Z,z[U]=G,cl=U;else break l}}return _}function H(z,_){var G=z.sortIndex-_.sortIndex;return G!==0?G:z.id-_.id}if(b.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var Y=performance;b.unstable_now=function(){return Y.now()}}else{var x=Date,P=x.now();b.unstable_now=function(){return x.now()-P}}var j=[],E=[],q=1,M=null,K=3,Tl=!1,Ol=!1,Ml=!1,Dt=!1,kl=typeof setTimeout=="function"?setTimeout:null,$t=typeof clearTimeout=="function"?clearTimeout:null,ql=typeof setImmediate<"u"?setImmediate:null;function it(z){for(var _=N(E);_!==null;){if(_.callback===null)h(E);else if(_.startTime<=z)h(E),_.sortIndex=_.expirationTime,X(j,_);else break;_=N(E)}}function Et(z){if(Ml=!1,it(z),!Ol)if(N(j)!==null)Ol=!0,Xl||(Xl=!0,Zl());else{var _=N(E);_!==null&&St(Et,_.startTime-z)}}var Xl=!1,F=-1,Ql=5,At=-1;function Za(){return Dt?!0:!(b.unstable_now()-Atz&&Za());){var cl=M.callback;if(typeof cl=="function"){M.callback=null,K=M.priorityLevel;var dl=cl(M.expirationTime<=z);if(z=b.unstable_now(),typeof dl=="function"){M.callback=dl,it(z),_=!0;break t}M===N(j)&&h(j),it(z)}else h(j);M=N(j)}if(M!==null)_=!0;else{var d=N(E);d!==null&&St(Et,d.startTime-z),_=!1}}break l}finally{M=null,K=G,Tl=!1}_=void 0}}finally{_?Zl():Xl=!1}}}var Zl;if(typeof ql=="function")Zl=function(){ql(pt)};else if(typeof MessageChannel<"u"){var Ta=new MessageChannel,Nt=Ta.port2;Ta.port1.onmessage=pt,Zl=function(){Nt.postMessage(null)}}else Zl=function(){kl(pt,0)};function St(z,_){F=kl(function(){z(b.unstable_now())},_)}b.unstable_IdlePriority=5,b.unstable_ImmediatePriority=1,b.unstable_LowPriority=4,b.unstable_NormalPriority=3,b.unstable_Profiling=null,b.unstable_UserBlockingPriority=2,b.unstable_cancelCallback=function(z){z.callback=null},b.unstable_forceFrameRate=function(z){0>z||125cl?(z.sortIndex=G,X(E,z),N(j)===null&&z===N(E)&&(Ml?($t(F),F=-1):Ml=!0,St(Et,G-cl))):(z.sortIndex=dl,X(j,z),Ol||Tl||(Ol=!0,Xl||(Xl=!0,Zl()))),z},b.unstable_shouldYield=Za,b.unstable_wrapCallback=function(z){var _=K;return function(){var G=K;K=_;try{return z.apply(this,arguments)}finally{K=G}}}})(of)),of}var Tm;function a0(){return Tm||(Tm=1,df.exports=t0()),df.exports}var mf={exports:{}},Bl={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Em;function e0(){if(Em)return Bl;Em=1;var b=hf();function X(j){var E="https://react.dev/errors/"+j;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(b)}catch(X){console.error(X)}}return b(),mf.exports=e0(),mf.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var pm;function n0(){if(pm)return Tu;pm=1;var b=a0(),X=hf(),N=u0();function h(l){var t="https://react.dev/errors/"+l;if(1dl||(l.current=cl[dl],cl[dl]=null,dl--)}function O(l,t){dl++,cl[dl]=l.current,l.current=t}var U=d(null),Z=d(null),J=d(null),el=d(null);function Yl(l,t){switch(O(J,t),O(Z,l),O(U,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?Go(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=Go(t),l=Xo(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}p(U),O(U,l)}function Sl(){p(U),p(Z),p(J)}function Me(l){l.memoizedState!==null&&O(el,l);var t=U.current,a=Xo(t,l.type);t!==a&&(O(Z,l),O(U,a))}function Au(l){Z.current===l&&(p(U),p(Z)),el.current===l&&(p(el),ru._currentValue=G)}var Zn,yf;function Ea(l){if(Zn===void 0)try{throw Error()}catch(a){var t=a.stack.trim().match(/\n( *(at )?)/);Zn=t&&t[1]||"",yf=-1)":-1u||f[e]!==v[u]){var S=` +`+f[e].replace(" at new "," at ");return l.displayName&&S.includes("")&&(S=S.replace("",l.displayName)),S}while(1<=e&&0<=u);break}}}finally{Ln=!1,Error.prepareStackTrace=a}return(a=l?l.displayName||l.name:"")?Ea(a):""}function jm(l,t){switch(l.tag){case 26:case 27:case 5:return Ea(l.type);case 16:return Ea("Lazy");case 13:return l.child!==t&&t!==null?Ea("Suspense Fallback"):Ea("Suspense");case 19:return Ea("SuspenseList");case 0:case 15:return Vn(l.type,!1);case 11:return Vn(l.type.render,!1);case 1:return Vn(l.type,!0);case 31:return Ea("Activity");default:return""}}function vf(l){try{var t="",a=null;do t+=jm(l,a),a=l,l=l.return;while(l);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var Kn=Object.prototype.hasOwnProperty,Jn=b.unstable_scheduleCallback,wn=b.unstable_cancelCallback,Dm=b.unstable_shouldYield,Nm=b.unstable_requestPaint,Fl=b.unstable_now,xm=b.unstable_getCurrentPriorityLevel,rf=b.unstable_ImmediatePriority,gf=b.unstable_UserBlockingPriority,pu=b.unstable_NormalPriority,Um=b.unstable_LowPriority,Sf=b.unstable_IdlePriority,Rm=b.log,Hm=b.unstable_setDisableYieldValue,je=null,Il=null;function kt(l){if(typeof Rm=="function"&&Hm(l),Il&&typeof Il.setStrictMode=="function")try{Il.setStrictMode(je,l)}catch{}}var Pl=Math.clz32?Math.clz32:Bm,Cm=Math.log,qm=Math.LN2;function Bm(l){return l>>>=0,l===0?32:31-(Cm(l)/qm|0)|0}var _u=256,Ou=262144,Mu=4194304;function Aa(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function ju(l,t,a){var e=l.pendingLanes;if(e===0)return 0;var u=0,n=l.suspendedLanes,c=l.pingedLanes;l=l.warmLanes;var i=e&134217727;return i!==0?(e=i&~n,e!==0?u=Aa(e):(c&=i,c!==0?u=Aa(c):a||(a=i&~l,a!==0&&(u=Aa(a))))):(i=e&~n,i!==0?u=Aa(i):c!==0?u=Aa(c):a||(a=e&~l,a!==0&&(u=Aa(a)))),u===0?0:t!==0&&t!==u&&(t&n)===0&&(n=u&-u,a=t&-t,n>=a||n===32&&(a&4194048)!==0)?t:u}function De(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Ym(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function bf(){var l=Mu;return Mu<<=1,(Mu&62914560)===0&&(Mu=4194304),l}function Wn(l){for(var t=[],a=0;31>a;a++)t.push(l);return t}function Ne(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Gm(l,t,a,e,u,n){var c=l.pendingLanes;l.pendingLanes=a,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=a,l.entangledLanes&=a,l.errorRecoveryDisabledLanes&=a,l.shellSuspendCounter=0;var i=l.entanglements,f=l.expirationTimes,v=l.hiddenUpdates;for(a=c&~a;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var Km=/[\n"\\]/g;function st(l){return l.replace(Km,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function lc(l,t,a,e,u,n,c,i){l.name="",c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?l.type=c:l.removeAttribute("type"),t!=null?c==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+ft(t)):l.value!==""+ft(t)&&(l.value=""+ft(t)):c!=="submit"&&c!=="reset"||l.removeAttribute("value"),t!=null?tc(l,c,ft(t)):a!=null?tc(l,c,ft(a)):e!=null&&l.removeAttribute("value"),u==null&&n!=null&&(l.defaultChecked=!!n),u!=null&&(l.checked=u&&typeof u!="function"&&typeof u!="symbol"),i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?l.name=""+ft(i):l.removeAttribute("name")}function Uf(l,t,a,e,u,n,c,i){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(l.type=n),t!=null||a!=null){if(!(n!=="submit"&&n!=="reset"||t!=null)){Pn(l);return}a=a!=null?""+ft(a):"",t=t!=null?""+ft(t):a,i||t===l.value||(l.value=t),l.defaultValue=t}e=e??u,e=typeof e!="function"&&typeof e!="symbol"&&!!e,l.checked=i?l.checked:!!e,l.defaultChecked=!!e,c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(l.name=c),Pn(l)}function tc(l,t,a){t==="number"&&xu(l.ownerDocument)===l||l.defaultValue===""+a||(l.defaultValue=""+a)}function Wa(l,t,a,e){if(l=l.options,t){t={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),cc=!1;if(Rt)try{var He={};Object.defineProperty(He,"passive",{get:function(){cc=!0}}),window.addEventListener("test",He,He),window.removeEventListener("test",He,He)}catch{cc=!1}var It=null,ic=null,Ru=null;function Gf(){if(Ru)return Ru;var l,t=ic,a=t.length,e,u="value"in It?It.value:It.textContent,n=u.length;for(l=0;l=Be),Kf=" ",Jf=!1;function wf(l,t){switch(l){case"keyup":return bh.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Wf(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var Ia=!1;function Th(l,t){switch(l){case"compositionend":return Wf(t);case"keypress":return t.which!==32?null:(Jf=!0,Kf);case"textInput":return l=t.data,l===Kf&&Jf?null:l;default:return null}}function Eh(l,t){if(Ia)return l==="compositionend"||!mc&&wf(l,t)?(l=Gf(),Ru=ic=It=null,Ia=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:a,offset:t-l};l=e}l:{for(;a;){if(a.nextSibling){a=a.nextSibling;break l}a=a.parentNode}a=void 0}a=as(a)}}function us(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?us(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function ns(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=xu(l.document);t instanceof l.HTMLIFrameElement;){try{var a=typeof t.contentWindow.location.href=="string"}catch{a=!1}if(a)l=t.contentWindow;else break;t=xu(l.document)}return t}function vc(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var Nh=Rt&&"documentMode"in document&&11>=document.documentMode,Pa=null,rc=null,Qe=null,gc=!1;function cs(l,t,a){var e=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;gc||Pa==null||Pa!==xu(e)||(e=Pa,"selectionStart"in e&&vc(e)?e={start:e.selectionStart,end:e.selectionEnd}:(e=(e.ownerDocument&&e.ownerDocument.defaultView||window).getSelection(),e={anchorNode:e.anchorNode,anchorOffset:e.anchorOffset,focusNode:e.focusNode,focusOffset:e.focusOffset}),Qe&&Xe(Qe,e)||(Qe=e,e=Mn(rc,"onSelect"),0>=c,u-=c,_t=1<<32-Pl(t)+u|a<V?(k=R,R=null):k=R.sibling;var tl=r(m,R,y[V],T);if(tl===null){R===null&&(R=k);break}l&&R&&tl.alternate===null&&t(m,R),s=n(tl,s,V),ll===null?C=tl:ll.sibling=tl,ll=tl,R=k}if(V===y.length)return a(m,R),I&&Ct(m,V),C;if(R===null){for(;VV?(k=R,R=null):k=R.sibling;var za=r(m,R,tl.value,T);if(za===null){R===null&&(R=k);break}l&&R&&za.alternate===null&&t(m,R),s=n(za,s,V),ll===null?C=za:ll.sibling=za,ll=za,R=k}if(tl.done)return a(m,R),I&&Ct(m,V),C;if(R===null){for(;!tl.done;V++,tl=y.next())tl=A(m,tl.value,T),tl!==null&&(s=n(tl,s,V),ll===null?C=tl:ll.sibling=tl,ll=tl);return I&&Ct(m,V),C}for(R=e(R);!tl.done;V++,tl=y.next())tl=g(R,m,V,tl.value,T),tl!==null&&(l&&tl.alternate!==null&&R.delete(tl.key===null?V:tl.key),s=n(tl,s,V),ll===null?C=tl:ll.sibling=tl,ll=tl);return l&&R.forEach(function(ky){return t(m,ky)}),I&&Ct(m,V),C}function sl(m,s,y,T){if(typeof y=="object"&&y!==null&&y.type===Ml&&y.key===null&&(y=y.props.children),typeof y=="object"&&y!==null){switch(y.$$typeof){case Tl:l:{for(var C=y.key;s!==null;){if(s.key===C){if(C=y.type,C===Ml){if(s.tag===7){a(m,s.sibling),T=u(s,y.props.children),T.return=m,m=T;break l}}else if(s.elementType===C||typeof C=="object"&&C!==null&&C.$$typeof===Ql&&Ha(C)===s.type){a(m,s.sibling),T=u(s,y.props),we(T,y),T.return=m,m=T;break l}a(m,s);break}else t(m,s);s=s.sibling}y.type===Ml?(T=Da(y.props.children,m.mode,T,y.key),T.return=m,m=T):(T=Lu(y.type,y.key,y.props,null,m.mode,T),we(T,y),T.return=m,m=T)}return c(m);case Ol:l:{for(C=y.key;s!==null;){if(s.key===C)if(s.tag===4&&s.stateNode.containerInfo===y.containerInfo&&s.stateNode.implementation===y.implementation){a(m,s.sibling),T=u(s,y.children||[]),T.return=m,m=T;break l}else{a(m,s);break}else t(m,s);s=s.sibling}T=pc(y,m.mode,T),T.return=m,m=T}return c(m);case Ql:return y=Ha(y),sl(m,s,y,T)}if(St(y))return D(m,s,y,T);if(Zl(y)){if(C=Zl(y),typeof C!="function")throw Error(h(150));return y=C.call(y),B(m,s,y,T)}if(typeof y.then=="function")return sl(m,s,ku(y),T);if(y.$$typeof===ql)return sl(m,s,Ju(m,y),T);Fu(m,y)}return typeof y=="string"&&y!==""||typeof y=="number"||typeof y=="bigint"?(y=""+y,s!==null&&s.tag===6?(a(m,s.sibling),T=u(s,y),T.return=m,m=T):(a(m,s),T=Ac(y,m.mode,T),T.return=m,m=T),c(m)):a(m,s)}return function(m,s,y,T){try{Je=0;var C=sl(m,s,y,T);return de=null,C}catch(R){if(R===se||R===Wu)throw R;var ll=tt(29,R,null,m.mode);return ll.lanes=T,ll.return=m,ll}finally{}}}var qa=Ds(!0),Ns=Ds(!1),ea=!1;function qc(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Bc(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function ua(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function na(l,t,a){var e=l.updateQueue;if(e===null)return null;if(e=e.shared,(al&2)!==0){var u=e.pending;return u===null?t.next=t:(t.next=u.next,u.next=t),e.pending=t,t=Zu(l),hs(l,null,a),t}return Qu(l,e,t,a),Zu(l)}function We(l,t,a){if(t=t.updateQueue,t!==null&&(t=t.shared,(a&4194048)!==0)){var e=t.lanes;e&=l.pendingLanes,a|=e,t.lanes=a,Tf(l,a)}}function Yc(l,t){var a=l.updateQueue,e=l.alternate;if(e!==null&&(e=e.updateQueue,a===e)){var u=null,n=null;if(a=a.firstBaseUpdate,a!==null){do{var c={lane:a.lane,tag:a.tag,payload:a.payload,callback:null,next:null};n===null?u=n=c:n=n.next=c,a=a.next}while(a!==null);n===null?u=n=t:n=n.next=t}else u=n=t;a={baseState:e.baseState,firstBaseUpdate:u,lastBaseUpdate:n,shared:e.shared,callbacks:e.callbacks},l.updateQueue=a;return}l=a.lastBaseUpdate,l===null?a.firstBaseUpdate=t:l.next=t,a.lastBaseUpdate=t}var Gc=!1;function $e(){if(Gc){var l=fe;if(l!==null)throw l}}function ke(l,t,a,e){Gc=!1;var u=l.updateQueue;ea=!1;var n=u.firstBaseUpdate,c=u.lastBaseUpdate,i=u.shared.pending;if(i!==null){u.shared.pending=null;var f=i,v=f.next;f.next=null,c===null?n=v:c.next=v,c=f;var S=l.alternate;S!==null&&(S=S.updateQueue,i=S.lastBaseUpdate,i!==c&&(i===null?S.firstBaseUpdate=v:i.next=v,S.lastBaseUpdate=f))}if(n!==null){var A=u.baseState;c=0,S=v=f=null,i=n;do{var r=i.lane&-536870913,g=r!==i.lane;if(g?($&r)===r:(e&r)===r){r!==0&&r===ie&&(Gc=!0),S!==null&&(S=S.next={lane:0,tag:i.tag,payload:i.payload,callback:null,next:null});l:{var D=l,B=i;r=t;var sl=a;switch(B.tag){case 1:if(D=B.payload,typeof D=="function"){A=D.call(sl,A,r);break l}A=D;break l;case 3:D.flags=D.flags&-65537|128;case 0:if(D=B.payload,r=typeof D=="function"?D.call(sl,A,r):D,r==null)break l;A=M({},A,r);break l;case 2:ea=!0}}r=i.callback,r!==null&&(l.flags|=64,g&&(l.flags|=8192),g=u.callbacks,g===null?u.callbacks=[r]:g.push(r))}else g={lane:r,tag:i.tag,payload:i.payload,callback:i.callback,next:null},S===null?(v=S=g,f=A):S=S.next=g,c|=r;if(i=i.next,i===null){if(i=u.shared.pending,i===null)break;g=i,i=g.next,g.next=null,u.lastBaseUpdate=g,u.shared.pending=null}}while(!0);S===null&&(f=A),u.baseState=f,u.firstBaseUpdate=v,u.lastBaseUpdate=S,n===null&&(u.shared.lanes=0),da|=c,l.lanes=c,l.memoizedState=A}}function xs(l,t){if(typeof l!="function")throw Error(h(191,l));l.call(t)}function Us(l,t){var a=l.callbacks;if(a!==null)for(l.callbacks=null,l=0;ln?n:8;var c=z.T,i={};z.T=i,ui(l,!1,t,a);try{var f=u(),v=z.S;if(v!==null&&v(i,f),f!==null&&typeof f=="object"&&typeof f.then=="function"){var S=Gh(f,e);Pe(l,t,S,ct(l))}else Pe(l,t,e,ct(l))}catch(A){Pe(l,t,{then:function(){},status:"rejected",reason:A},ct())}finally{_.p=n,c!==null&&i.types!==null&&(c.types=i.types),z.T=c}}function Kh(){}function ai(l,t,a,e){if(l.tag!==5)throw Error(h(476));var u=dd(l).queue;sd(l,u,t,G,a===null?Kh:function(){return od(l),a(e)})}function dd(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:G,baseState:G,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Gt,lastRenderedState:G},next:null};var a={};return t.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Gt,lastRenderedState:a},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function od(l){var t=dd(l);t.next===null&&(t=l.alternate.memoizedState),Pe(l,t.next.queue,{},ct())}function ei(){return Rl(ru)}function md(){return zl().memoizedState}function hd(){return zl().memoizedState}function Jh(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var a=ct();l=ua(a);var e=na(t,l,a);e!==null&&($l(e,t,a),We(e,t,a)),t={cache:Uc()},l.payload=t;return}t=t.return}}function wh(l,t,a){var e=ct();a={lane:e,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},fn(l)?vd(t,a):(a=Tc(l,t,a,e),a!==null&&($l(a,l,e),rd(a,t,e)))}function yd(l,t,a){var e=ct();Pe(l,t,a,e)}function Pe(l,t,a,e){var u={lane:e,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null};if(fn(l))vd(t,u);else{var n=l.alternate;if(l.lanes===0&&(n===null||n.lanes===0)&&(n=t.lastRenderedReducer,n!==null))try{var c=t.lastRenderedState,i=n(c,a);if(u.hasEagerState=!0,u.eagerState=i,lt(i,c))return Qu(l,t,u,0),ol===null&&Xu(),!1}catch{}finally{}if(a=Tc(l,t,u,e),a!==null)return $l(a,l,e),rd(a,t,e),!0}return!1}function ui(l,t,a,e){if(e={lane:2,revertLane:qi(),gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},fn(l)){if(t)throw Error(h(479))}else t=Tc(l,a,e,2),t!==null&&$l(t,l,2)}function fn(l){var t=l.alternate;return l===L||t!==null&&t===L}function vd(l,t){me=ln=!0;var a=l.pending;a===null?t.next=t:(t.next=a.next,a.next=t),l.pending=t}function rd(l,t,a){if((a&4194048)!==0){var e=t.lanes;e&=l.pendingLanes,a|=e,t.lanes=a,Tf(l,a)}}var lu={readContext:Rl,use:en,useCallback:rl,useContext:rl,useEffect:rl,useImperativeHandle:rl,useLayoutEffect:rl,useInsertionEffect:rl,useMemo:rl,useReducer:rl,useRef:rl,useState:rl,useDebugValue:rl,useDeferredValue:rl,useTransition:rl,useSyncExternalStore:rl,useId:rl,useHostTransitionStatus:rl,useFormState:rl,useActionState:rl,useOptimistic:rl,useMemoCache:rl,useCacheRefresh:rl};lu.useEffectEvent=rl;var gd={readContext:Rl,use:en,useCallback:function(l,t){return Gl().memoizedState=[l,t===void 0?null:t],l},useContext:Rl,useEffect:ld,useImperativeHandle:function(l,t,a){a=a!=null?a.concat([l]):null,nn(4194308,4,ud.bind(null,t,l),a)},useLayoutEffect:function(l,t){return nn(4194308,4,l,t)},useInsertionEffect:function(l,t){nn(4,2,l,t)},useMemo:function(l,t){var a=Gl();t=t===void 0?null:t;var e=l();if(Ba){kt(!0);try{l()}finally{kt(!1)}}return a.memoizedState=[e,t],e},useReducer:function(l,t,a){var e=Gl();if(a!==void 0){var u=a(t);if(Ba){kt(!0);try{a(t)}finally{kt(!1)}}}else u=t;return e.memoizedState=e.baseState=u,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:u},e.queue=l,l=l.dispatch=wh.bind(null,L,l),[e.memoizedState,l]},useRef:function(l){var t=Gl();return l={current:l},t.memoizedState=l},useState:function(l){l=Fc(l);var t=l.queue,a=yd.bind(null,L,t);return t.dispatch=a,[l.memoizedState,a]},useDebugValue:li,useDeferredValue:function(l,t){var a=Gl();return ti(a,l,t)},useTransition:function(){var l=Fc(!1);return l=sd.bind(null,L,l.queue,!0,!1),Gl().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,a){var e=L,u=Gl();if(I){if(a===void 0)throw Error(h(407));a=a()}else{if(a=t(),ol===null)throw Error(h(349));($&127)!==0||Ys(e,t,a)}u.memoizedState=a;var n={value:a,getSnapshot:t};return u.queue=n,ld(Xs.bind(null,e,n,l),[l]),e.flags|=2048,ye(9,{destroy:void 0},Gs.bind(null,e,n,a,t),null),a},useId:function(){var l=Gl(),t=ol.identifierPrefix;if(I){var a=Ot,e=_t;a=(e&~(1<<32-Pl(e)-1)).toString(32)+a,t="_"+t+"R_"+a,a=tn++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof e.is=="string"?c.createElement("select",{is:e.is}):c.createElement("select"),e.multiple?n.multiple=!0:e.size&&(n.size=e.size);break;default:n=typeof e.is=="string"?c.createElement(u,{is:e.is}):c.createElement(u)}}n[xl]=t,n[Ll]=e;l:for(c=t.child;c!==null;){if(c.tag===5||c.tag===6)n.appendChild(c.stateNode);else if(c.tag!==4&&c.tag!==27&&c.child!==null){c.child.return=c,c=c.child;continue}if(c===t)break l;for(;c.sibling===null;){if(c.return===null||c.return===t)break l;c=c.return}c.sibling.return=c.return,c=c.sibling}t.stateNode=n;l:switch(Cl(n,u,e),u){case"button":case"input":case"select":case"textarea":e=!!e.autoFocus;break l;case"img":e=!0;break l;default:e=!1}e&&Qt(t)}}return hl(t),Si(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,a),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==e&&Qt(t);else{if(typeof e!="string"&&t.stateNode===null)throw Error(h(166));if(l=J.current,ne(t)){if(l=t.stateNode,a=t.memoizedProps,e=null,u=Ul,u!==null)switch(u.tag){case 27:case 5:e=u.memoizedProps}l[xl]=t,l=!!(l.nodeValue===a||e!==null&&e.suppressHydrationWarning===!0||Bo(l.nodeValue,a)),l||ta(t,!0)}else l=jn(l).createTextNode(e),l[xl]=t,t.stateNode=l}return hl(t),null;case 31:if(a=t.memoizedState,l===null||l.memoizedState!==null){if(e=ne(t),a!==null){if(l===null){if(!e)throw Error(h(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(h(557));l[xl]=t}else Na(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;hl(t),l=!1}else a=jc(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=a),l=!0;if(!l)return t.flags&256?(et(t),t):(et(t),null);if((t.flags&128)!==0)throw Error(h(558))}return hl(t),null;case 13:if(e=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(u=ne(t),e!==null&&e.dehydrated!==null){if(l===null){if(!u)throw Error(h(318));if(u=t.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(h(317));u[xl]=t}else Na(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;hl(t),u=!1}else u=jc(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=u),u=!0;if(!u)return t.flags&256?(et(t),t):(et(t),null)}return et(t),(t.flags&128)!==0?(t.lanes=a,t):(a=e!==null,l=l!==null&&l.memoizedState!==null,a&&(e=t.child,u=null,e.alternate!==null&&e.alternate.memoizedState!==null&&e.alternate.memoizedState.cachePool!==null&&(u=e.alternate.memoizedState.cachePool.pool),n=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),n!==u&&(e.flags|=2048)),a!==l&&a&&(t.child.flags|=8192),hn(t,t.updateQueue),hl(t),null);case 4:return Sl(),l===null&&Xi(t.stateNode.containerInfo),hl(t),null;case 10:return Bt(t.type),hl(t),null;case 19:if(p(bl),e=t.memoizedState,e===null)return hl(t),null;if(u=(t.flags&128)!==0,n=e.rendering,n===null)if(u)au(e,!1);else{if(gl!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(n=Pu(l),n!==null){for(t.flags|=128,au(e,!1),l=n.updateQueue,t.updateQueue=l,hn(t,l),t.subtreeFlags=0,l=a,a=t.child;a!==null;)ys(a,l),a=a.sibling;return O(bl,bl.current&1|2),I&&Ct(t,e.treeForkCount),t.child}l=l.sibling}e.tail!==null&&Fl()>Sn&&(t.flags|=128,u=!0,au(e,!1),t.lanes=4194304)}else{if(!u)if(l=Pu(n),l!==null){if(t.flags|=128,u=!0,l=l.updateQueue,t.updateQueue=l,hn(t,l),au(e,!0),e.tail===null&&e.tailMode==="hidden"&&!n.alternate&&!I)return hl(t),null}else 2*Fl()-e.renderingStartTime>Sn&&a!==536870912&&(t.flags|=128,u=!0,au(e,!1),t.lanes=4194304);e.isBackwards?(n.sibling=t.child,t.child=n):(l=e.last,l!==null?l.sibling=n:t.child=n,e.last=n)}return e.tail!==null?(l=e.tail,e.rendering=l,e.tail=l.sibling,e.renderingStartTime=Fl(),l.sibling=null,a=bl.current,O(bl,u?a&1|2:a&1),I&&Ct(t,e.treeForkCount),l):(hl(t),null);case 22:case 23:return et(t),Qc(),e=t.memoizedState!==null,l!==null?l.memoizedState!==null!==e&&(t.flags|=8192):e&&(t.flags|=8192),e?(a&536870912)!==0&&(t.flags&128)===0&&(hl(t),t.subtreeFlags&6&&(t.flags|=8192)):hl(t),a=t.updateQueue,a!==null&&hn(t,a.retryQueue),a=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==a&&(t.flags|=2048),l!==null&&p(Ra),null;case 24:return a=null,l!==null&&(a=l.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),Bt(El),hl(t),null;case 25:return null;case 30:return null}throw Error(h(156,t.tag))}function Ih(l,t){switch(Oc(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return Bt(El),Sl(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return Au(t),null;case 31:if(t.memoizedState!==null){if(et(t),t.alternate===null)throw Error(h(340));Na()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(et(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(h(340));Na()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return p(bl),null;case 4:return Sl(),null;case 10:return Bt(t.type),null;case 22:case 23:return et(t),Qc(),l!==null&&p(Ra),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return Bt(El),null;case 25:return null;default:return null}}function Qd(l,t){switch(Oc(t),t.tag){case 3:Bt(El),Sl();break;case 26:case 27:case 5:Au(t);break;case 4:Sl();break;case 31:t.memoizedState!==null&&et(t);break;case 13:et(t);break;case 19:p(bl);break;case 10:Bt(t.type);break;case 22:case 23:et(t),Qc(),l!==null&&p(Ra);break;case 24:Bt(El)}}function eu(l,t){try{var a=t.updateQueue,e=a!==null?a.lastEffect:null;if(e!==null){var u=e.next;a=u;do{if((a.tag&l)===l){e=void 0;var n=a.create,c=a.inst;e=n(),c.destroy=e}a=a.next}while(a!==u)}}catch(i){nl(t,t.return,i)}}function fa(l,t,a){try{var e=t.updateQueue,u=e!==null?e.lastEffect:null;if(u!==null){var n=u.next;e=n;do{if((e.tag&l)===l){var c=e.inst,i=c.destroy;if(i!==void 0){c.destroy=void 0,u=t;var f=a,v=i;try{v()}catch(S){nl(u,f,S)}}}e=e.next}while(e!==n)}}catch(S){nl(t,t.return,S)}}function Zd(l){var t=l.updateQueue;if(t!==null){var a=l.stateNode;try{Us(t,a)}catch(e){nl(l,l.return,e)}}}function Ld(l,t,a){a.props=Ya(l.type,l.memoizedProps),a.state=l.memoizedState;try{a.componentWillUnmount()}catch(e){nl(l,t,e)}}function uu(l,t){try{var a=l.ref;if(a!==null){switch(l.tag){case 26:case 27:case 5:var e=l.stateNode;break;case 30:e=l.stateNode;break;default:e=l.stateNode}typeof a=="function"?l.refCleanup=a(e):a.current=e}}catch(u){nl(l,t,u)}}function Mt(l,t){var a=l.ref,e=l.refCleanup;if(a!==null)if(typeof e=="function")try{e()}catch(u){nl(l,t,u)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof a=="function")try{a(null)}catch(u){nl(l,t,u)}else a.current=null}function Vd(l){var t=l.type,a=l.memoizedProps,e=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":a.autoFocus&&e.focus();break l;case"img":a.src?e.src=a.src:a.srcSet&&(e.srcset=a.srcSet)}}catch(u){nl(l,l.return,u)}}function bi(l,t,a){try{var e=l.stateNode;zy(e,l.type,a,t),e[Ll]=t}catch(u){nl(l,l.return,u)}}function Kd(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&va(l.type)||l.tag===4}function zi(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||Kd(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&va(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function Ti(l,t,a){var e=l.tag;if(e===5||e===6)l=l.stateNode,t?(a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a).insertBefore(l,t):(t=a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a,t.appendChild(l),a=a._reactRootContainer,a!=null||t.onclick!==null||(t.onclick=Ut));else if(e!==4&&(e===27&&va(l.type)&&(a=l.stateNode,t=null),l=l.child,l!==null))for(Ti(l,t,a),l=l.sibling;l!==null;)Ti(l,t,a),l=l.sibling}function yn(l,t,a){var e=l.tag;if(e===5||e===6)l=l.stateNode,t?a.insertBefore(l,t):a.appendChild(l);else if(e!==4&&(e===27&&va(l.type)&&(a=l.stateNode),l=l.child,l!==null))for(yn(l,t,a),l=l.sibling;l!==null;)yn(l,t,a),l=l.sibling}function Jd(l){var t=l.stateNode,a=l.memoizedProps;try{for(var e=l.type,u=t.attributes;u.length;)t.removeAttributeNode(u[0]);Cl(t,e,a),t[xl]=l,t[Ll]=a}catch(n){nl(l,l.return,n)}}var Zt=!1,_l=!1,Ei=!1,wd=typeof WeakSet=="function"?WeakSet:Set,Nl=null;function Ph(l,t){if(l=l.containerInfo,Li=Cn,l=ns(l),vc(l)){if("selectionStart"in l)var a={start:l.selectionStart,end:l.selectionEnd};else l:{a=(a=l.ownerDocument)&&a.defaultView||window;var e=a.getSelection&&a.getSelection();if(e&&e.rangeCount!==0){a=e.anchorNode;var u=e.anchorOffset,n=e.focusNode;e=e.focusOffset;try{a.nodeType,n.nodeType}catch{a=null;break l}var c=0,i=-1,f=-1,v=0,S=0,A=l,r=null;t:for(;;){for(var g;A!==a||u!==0&&A.nodeType!==3||(i=c+u),A!==n||e!==0&&A.nodeType!==3||(f=c+e),A.nodeType===3&&(c+=A.nodeValue.length),(g=A.firstChild)!==null;)r=A,A=g;for(;;){if(A===l)break t;if(r===a&&++v===u&&(i=c),r===n&&++S===e&&(f=c),(g=A.nextSibling)!==null)break;A=r,r=A.parentNode}A=g}a=i===-1||f===-1?null:{start:i,end:f}}else a=null}a=a||{start:0,end:0}}else a=null;for(Vi={focusedElem:l,selectionRange:a},Cn=!1,Nl=t;Nl!==null;)if(t=Nl,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,Nl=l;else for(;Nl!==null;){switch(t=Nl,n=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(a=0;a title"))),Cl(n,e,a),n[xl]=l,Dl(n),e=n;break l;case"link":var c=lm("link","href",u).get(e+(a.href||""));if(c){for(var i=0;isl&&(c=sl,sl=B,B=c);var m=es(i,B),s=es(i,sl);if(m&&s&&(g.rangeCount!==1||g.anchorNode!==m.node||g.anchorOffset!==m.offset||g.focusNode!==s.node||g.focusOffset!==s.offset)){var y=A.createRange();y.setStart(m.node,m.offset),g.removeAllRanges(),B>sl?(g.addRange(y),g.extend(s.node,s.offset)):(y.setEnd(s.node,s.offset),g.addRange(y))}}}}for(A=[],g=i;g=g.parentNode;)g.nodeType===1&&A.push({element:g,left:g.scrollLeft,top:g.scrollTop});for(typeof i.focus=="function"&&i.focus(),i=0;ia?32:a,z.T=null,a=Di,Di=null;var n=ma,c=wt;if(jl=0,be=ma=null,wt=0,(al&6)!==0)throw Error(h(331));var i=al;if(al|=4,uo(n.current),to(n,n.current,c,a),al=i,du(0,!1),Il&&typeof Il.onPostCommitFiberRoot=="function")try{Il.onPostCommitFiberRoot(je,n)}catch{}return!0}finally{_.p=u,z.T=e,Ao(l,t)}}function _o(l,t,a){t=ot(a,t),t=fi(l.stateNode,t,2),l=na(l,t,2),l!==null&&(Ne(l,2),jt(l))}function nl(l,t,a){if(l.tag===3)_o(l,l,a);else for(;t!==null;){if(t.tag===3){_o(t,l,a);break}else if(t.tag===1){var e=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof e.componentDidCatch=="function"&&(oa===null||!oa.has(e))){l=ot(a,l),a=_d(2),e=na(t,a,2),e!==null&&(Od(a,e,t,l),Ne(e,2),jt(e));break}}t=t.return}}function Ri(l,t,a){var e=l.pingCache;if(e===null){e=l.pingCache=new ay;var u=new Set;e.set(t,u)}else u=e.get(t),u===void 0&&(u=new Set,e.set(t,u));u.has(a)||(_i=!0,u.add(a),l=iy.bind(null,l,t,a),t.then(l,l))}function iy(l,t,a){var e=l.pingCache;e!==null&&e.delete(t),l.pingedLanes|=l.suspendedLanes&a,l.warmLanes&=~a,ol===l&&($&a)===a&&(gl===4||gl===3&&($&62914560)===$&&300>Fl()-gn?(al&2)===0&&ze(l,0):Oi|=a,Se===$&&(Se=0)),jt(l)}function Oo(l,t){t===0&&(t=bf()),l=ja(l,t),l!==null&&(Ne(l,t),jt(l))}function fy(l){var t=l.memoizedState,a=0;t!==null&&(a=t.retryLane),Oo(l,a)}function sy(l,t){var a=0;switch(l.tag){case 31:case 13:var e=l.stateNode,u=l.memoizedState;u!==null&&(a=u.retryLane);break;case 19:e=l.stateNode;break;case 22:e=l.stateNode._retryCache;break;default:throw Error(h(314))}e!==null&&e.delete(t),Oo(l,a)}function dy(l,t){return Jn(l,t)}var pn=null,Ee=null,Hi=!1,_n=!1,Ci=!1,ya=0;function jt(l){l!==Ee&&l.next===null&&(Ee===null?pn=Ee=l:Ee=Ee.next=l),_n=!0,Hi||(Hi=!0,my())}function du(l,t){if(!Ci&&_n){Ci=!0;do for(var a=!1,e=pn;e!==null;){if(l!==0){var u=e.pendingLanes;if(u===0)var n=0;else{var c=e.suspendedLanes,i=e.pingedLanes;n=(1<<31-Pl(42|l)+1)-1,n&=u&~(c&~i),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(a=!0,No(e,n))}else n=$,n=ju(e,e===ol?n:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),(n&3)===0||De(e,n)||(a=!0,No(e,n));e=e.next}while(a);Ci=!1}}function oy(){Mo()}function Mo(){_n=Hi=!1;var l=0;ya!==0&&Ey()&&(l=ya);for(var t=Fl(),a=null,e=pn;e!==null;){var u=e.next,n=jo(e,t);n===0?(e.next=null,a===null?pn=u:a.next=u,u===null&&(Ee=a)):(a=e,(l!==0||(n&3)!==0)&&(_n=!0)),e=u}jl!==0&&jl!==5||du(l),ya!==0&&(ya=0)}function jo(l,t){for(var a=l.suspendedLanes,e=l.pingedLanes,u=l.expirationTimes,n=l.pendingLanes&-62914561;0i)break;var S=f.transferSize,A=f.initiatorType;S&&Yo(A)&&(f=f.responseEnd,c+=S*(f"u"?null:document;function ko(l,t,a){var e=Ae;if(e&&typeof t=="string"&&t){var u=st(t);u='link[rel="'+l+'"][href="'+u+'"]',typeof a=="string"&&(u+='[crossorigin="'+a+'"]'),$o.has(u)||($o.add(u),l={rel:l,crossOrigin:a,href:t},e.querySelector(u)===null&&(t=e.createElement("link"),Cl(t,"link",l),Dl(t),e.head.appendChild(t)))}}function xy(l){Wt.D(l),ko("dns-prefetch",l,null)}function Uy(l,t){Wt.C(l,t),ko("preconnect",l,t)}function Ry(l,t,a){Wt.L(l,t,a);var e=Ae;if(e&&l&&t){var u='link[rel="preload"][as="'+st(t)+'"]';t==="image"&&a&&a.imageSrcSet?(u+='[imagesrcset="'+st(a.imageSrcSet)+'"]',typeof a.imageSizes=="string"&&(u+='[imagesizes="'+st(a.imageSizes)+'"]')):u+='[href="'+st(l)+'"]';var n=u;switch(t){case"style":n=pe(l);break;case"script":n=_e(l)}gt.has(n)||(l=M({rel:"preload",href:t==="image"&&a&&a.imageSrcSet?void 0:l,as:t},a),gt.set(n,l),e.querySelector(u)!==null||t==="style"&&e.querySelector(yu(n))||t==="script"&&e.querySelector(vu(n))||(t=e.createElement("link"),Cl(t,"link",l),Dl(t),e.head.appendChild(t)))}}function Hy(l,t){Wt.m(l,t);var a=Ae;if(a&&l){var e=t&&typeof t.as=="string"?t.as:"script",u='link[rel="modulepreload"][as="'+st(e)+'"][href="'+st(l)+'"]',n=u;switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=_e(l)}if(!gt.has(n)&&(l=M({rel:"modulepreload",href:l},t),gt.set(n,l),a.querySelector(u)===null)){switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(a.querySelector(vu(n)))return}e=a.createElement("link"),Cl(e,"link",l),Dl(e),a.head.appendChild(e)}}}function Cy(l,t,a){Wt.S(l,t,a);var e=Ae;if(e&&l){var u=Ja(e).hoistableStyles,n=pe(l);t=t||"default";var c=u.get(n);if(!c){var i={loading:0,preload:null};if(c=e.querySelector(yu(n)))i.loading=5;else{l=M({rel:"stylesheet",href:l,"data-precedence":t},a),(a=gt.get(n))&&Fi(l,a);var f=c=e.createElement("link");Dl(f),Cl(f,"link",l),f._p=new Promise(function(v,S){f.onload=v,f.onerror=S}),f.addEventListener("load",function(){i.loading|=1}),f.addEventListener("error",function(){i.loading|=2}),i.loading|=4,Nn(c,t,e)}c={type:"stylesheet",instance:c,count:1,state:i},u.set(n,c)}}}function qy(l,t){Wt.X(l,t);var a=Ae;if(a&&l){var e=Ja(a).hoistableScripts,u=_e(l),n=e.get(u);n||(n=a.querySelector(vu(u)),n||(l=M({src:l,async:!0},t),(t=gt.get(u))&&Ii(l,t),n=a.createElement("script"),Dl(n),Cl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(u,n))}}function By(l,t){Wt.M(l,t);var a=Ae;if(a&&l){var e=Ja(a).hoistableScripts,u=_e(l),n=e.get(u);n||(n=a.querySelector(vu(u)),n||(l=M({src:l,async:!0,type:"module"},t),(t=gt.get(u))&&Ii(l,t),n=a.createElement("script"),Dl(n),Cl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(u,n))}}function Fo(l,t,a,e){var u=(u=J.current)?Dn(u):null;if(!u)throw Error(h(446));switch(l){case"meta":case"title":return null;case"style":return typeof a.precedence=="string"&&typeof a.href=="string"?(t=pe(a.href),a=Ja(u).hoistableStyles,e=a.get(t),e||(e={type:"style",instance:null,count:0,state:null},a.set(t,e)),e):{type:"void",instance:null,count:0,state:null};case"link":if(a.rel==="stylesheet"&&typeof a.href=="string"&&typeof a.precedence=="string"){l=pe(a.href);var n=Ja(u).hoistableStyles,c=n.get(l);if(c||(u=u.ownerDocument||u,c={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(l,c),(n=u.querySelector(yu(l)))&&!n._p&&(c.instance=n,c.state.loading=5),gt.has(l)||(a={rel:"preload",as:"style",href:a.href,crossOrigin:a.crossOrigin,integrity:a.integrity,media:a.media,hrefLang:a.hrefLang,referrerPolicy:a.referrerPolicy},gt.set(l,a),n||Yy(u,l,a,c.state))),t&&e===null)throw Error(h(528,""));return c}if(t&&e!==null)throw Error(h(529,""));return null;case"script":return t=a.async,a=a.src,typeof a=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=_e(a),a=Ja(u).hoistableScripts,e=a.get(t),e||(e={type:"script",instance:null,count:0,state:null},a.set(t,e)),e):{type:"void",instance:null,count:0,state:null};default:throw Error(h(444,l))}}function pe(l){return'href="'+st(l)+'"'}function yu(l){return'link[rel="stylesheet"]['+l+"]"}function Io(l){return M({},l,{"data-precedence":l.precedence,precedence:null})}function Yy(l,t,a,e){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?e.loading=1:(t=l.createElement("link"),e.preload=t,t.addEventListener("load",function(){return e.loading|=1}),t.addEventListener("error",function(){return e.loading|=2}),Cl(t,"link",a),Dl(t),l.head.appendChild(t))}function _e(l){return'[src="'+st(l)+'"]'}function vu(l){return"script[async]"+l}function Po(l,t,a){if(t.count++,t.instance===null)switch(t.type){case"style":var e=l.querySelector('style[data-href~="'+st(a.href)+'"]');if(e)return t.instance=e,Dl(e),e;var u=M({},a,{"data-href":a.href,"data-precedence":a.precedence,href:null,precedence:null});return e=(l.ownerDocument||l).createElement("style"),Dl(e),Cl(e,"style",u),Nn(e,a.precedence,l),t.instance=e;case"stylesheet":u=pe(a.href);var n=l.querySelector(yu(u));if(n)return t.state.loading|=4,t.instance=n,Dl(n),n;e=Io(a),(u=gt.get(u))&&Fi(e,u),n=(l.ownerDocument||l).createElement("link"),Dl(n);var c=n;return c._p=new Promise(function(i,f){c.onload=i,c.onerror=f}),Cl(n,"link",e),t.state.loading|=4,Nn(n,a.precedence,l),t.instance=n;case"script":return n=_e(a.src),(u=l.querySelector(vu(n)))?(t.instance=u,Dl(u),u):(e=a,(u=gt.get(n))&&(e=M({},a),Ii(e,u)),l=l.ownerDocument||l,u=l.createElement("script"),Dl(u),Cl(u,"link",e),l.head.appendChild(u),t.instance=u);case"void":return null;default:throw Error(h(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(e=t.instance,t.state.loading|=4,Nn(e,a.precedence,l));return t.instance}function Nn(l,t,a){for(var e=a.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=e.length?e[e.length-1]:null,n=u,c=0;c title"):null)}function Gy(l,t,a){if(a===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return l=t.disabled,typeof t.precedence=="string"&&l==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function am(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function Xy(l,t,a,e){if(a.type==="stylesheet"&&(typeof e.media!="string"||matchMedia(e.media).matches!==!1)&&(a.state.loading&4)===0){if(a.instance===null){var u=pe(e.href),n=t.querySelector(yu(u));if(n){t=n._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Un.bind(l),t.then(l,l)),a.state.loading|=4,a.instance=n,Dl(n);return}n=t.ownerDocument||t,e=Io(e),(u=gt.get(u))&&Fi(e,u),n=n.createElement("link"),Dl(n);var c=n;c._p=new Promise(function(i,f){c.onload=i,c.onerror=f}),Cl(n,"link",e),a.instance=n}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(a,t),(t=a.state.preload)&&(a.state.loading&3)===0&&(l.count++,a=Un.bind(l),t.addEventListener("load",a),t.addEventListener("error",a))}}var Pi=0;function Qy(l,t){return l.stylesheets&&l.count===0&&Hn(l,l.stylesheets),0Pi?50:800)+t);return l.unsuspend=a,function(){l.unsuspend=null,clearTimeout(e),clearTimeout(u)}}:null}function Un(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Hn(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var Rn=null;function Hn(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,Rn=new Map,t.forEach(Zy,l),Rn=null,Un.call(l))}function Zy(l,t){if(!(t.state.loading&4)){var a=Rn.get(l);if(a)var e=a.get(null);else{a=new Map,Rn.set(l,a);for(var u=l.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(b)}catch(X){console.error(X)}}return b(),sf.exports=n0(),sf.exports}var i0=c0();const f0=Mm(i0),s0="";async function Eu(b,X){const N=await fetch(`${s0}${b}`,{...X,credentials:"same-origin",headers:{"Content-Type":"application/json",...X==null?void 0:X.headers}});if(N.status===401)throw window.location.hash="#login",new Error("Unauthorized");if(!N.ok){const h=await N.json().catch(()=>({}));throw new Error(h.error||`HTTP ${N.status}`)}return N.json()}const Qa={login:b=>Eu("/admin/login",{method:"POST",body:JSON.stringify({token:b})}),stats:()=>Eu("/admin/api/stats"),health:()=>Eu("/admin/api/health-indicators"),agents:()=>Eu("/admin/api/agents"),requests:(b=1)=>Eu(`/admin/api/requests?page=${b}`)};function d0({onLogin:b}){const[X,N]=yl.useState(""),[h,H]=yl.useState(""),[Y,x]=yl.useState(!1),P=async j=>{j.preventDefault(),H(""),x(!0);try{await Qa.login(X),b()}catch{H("Invalid token. Check your terminal output.")}finally{x(!1)}};return o.jsx("div",{className:"login-page",children:o.jsxs("form",{className:"login-box",onSubmit:P,children:[o.jsx("div",{className:"login-logo",children:"GBrain"}),o.jsx("div",{style:{marginBottom:16},children:o.jsx("input",{type:"password",placeholder:"Admin Token",value:X,onChange:j=>N(j.target.value),autoFocus:!0})}),o.jsx("button",{className:"btn btn-primary",style:{width:"100%"},disabled:Y,children:Y?"Authenticating...":"Submit"}),h&&o.jsx("div",{className:"login-error",children:h}),o.jsx("div",{className:"login-hint",children:"Find this token in your terminal output."})]})})}function o0(){const[b,X]=yl.useState({connected_agents:0,requests_today:0,active_tokens:0}),[N,h]=yl.useState({expiring_soon:0,error_rate:"0%"}),[H,Y]=yl.useState([]),[x,P]=yl.useState("connecting"),j=yl.useRef(null);yl.useEffect(()=>{Qa.stats().then(X).catch(()=>{}),Qa.health().then(h).catch(()=>{});const q=new EventSource("/admin/events");j.current=q,q.onopen=()=>P("connected"),q.onmessage=K=>{try{const Tl=JSON.parse(K.data);Y(Ol=>[Tl,...Ol].slice(0,50))}catch{}},q.onerror=()=>{P("disconnected"),setTimeout(()=>{P("connecting"),q.close()},3e3)};const M=setInterval(()=>{Qa.stats().then(X).catch(()=>{}),Qa.health().then(h).catch(()=>{})},3e4);return()=>{q.close(),clearInterval(M)}},[]);const E=q=>{const M=Date.now()-new Date(q).getTime();return M<6e4?`${Math.floor(M/1e3)}s ago`:M<36e5?`${Math.floor(M/6e4)} min ago`:`${Math.floor(M/36e5)}h ago`};return o.jsxs(o.Fragment,{children:[o.jsx("h1",{className:"page-title",children:"Dashboard"}),o.jsxs("div",{style:{display:"flex",gap:24},children:[o.jsxs("div",{style:{flex:1},children:[o.jsxs("div",{className:"metrics",children:[o.jsxs("div",{className:"metric",children:[o.jsx("div",{className:"metric-value",children:b.connected_agents}),o.jsx("div",{className:"metric-label",children:"Connected Agents"})]}),o.jsxs("div",{className:"metric",children:[o.jsx("div",{className:"metric-value",children:b.requests_today}),o.jsx("div",{className:"metric-label",children:"Requests Today"})]}),o.jsxs("div",{className:"metric",children:[o.jsx("div",{className:"metric-value",children:b.active_tokens}),o.jsx("div",{className:"metric-label",children:"Active Tokens"})]})]}),o.jsxs("h2",{className:"section-title",children:["Live Activity",o.jsx("span",{style:{marginLeft:8,fontSize:10,color:x==="connected"?"var(--success)":x==="connecting"?"var(--warning)":"var(--error)"},children:x==="connected"?"● connected":x==="connecting"?"● connecting...":"● disconnected"})]}),o.jsx("div",{className:"feed",children:H.length===0?o.jsx("div",{className:"feed-empty",children:x==="connected"?"No requests yet. Agents will appear when they connect.":"Connecting..."}):o.jsxs("table",{children:[o.jsx("thead",{children:o.jsxs("tr",{children:[o.jsx("th",{children:"Agent"}),o.jsx("th",{children:"Operation"}),o.jsx("th",{children:"Scopes"}),o.jsx("th",{children:"Latency"}),o.jsx("th",{children:"Status"}),o.jsx("th",{children:"Time"})]})}),o.jsx("tbody",{children:H.map((q,M)=>o.jsxs("tr",{children:[o.jsx("td",{className:"mono",children:q.agent}),o.jsx("td",{className:"mono",children:q.operation}),o.jsx("td",{children:q.scopes.split(",").map(K=>o.jsx("span",{className:`badge badge-${K.trim()}`,style:{marginRight:4},children:K.trim()},K))}),o.jsxs("td",{className:"mono",children:[q.latency_ms," ms"]}),o.jsx("td",{children:o.jsx("span",{className:`badge badge-${q.status}`,children:q.status})}),o.jsx("td",{style:{color:"var(--text-secondary)"},children:E(q.timestamp)})]},M))})]})})]}),o.jsxs("div",{style:{width:220},children:[o.jsx("h2",{className:"section-title",children:"Token Health"}),o.jsxs("div",{className:"health-panel",children:[o.jsxs("div",{className:"health-row",children:[o.jsx("span",{style:{color:"var(--warning)"},children:"Expiring Soon"}),o.jsx("span",{className:"mono",children:N.expiring_soon})]}),o.jsxs("div",{className:"health-row",children:[o.jsx("span",{style:{color:"var(--error)"},children:"Error Rate"}),o.jsx("span",{className:"mono",children:N.error_rate})]})]})]})]})]})}function m0(){const[b,X]=yl.useState([]),[N,h]=yl.useState(!1),[H,Y]=yl.useState(null),[x,P]=yl.useState(null);yl.useEffect(()=>{j()},[]);const j=()=>{Qa.agents().then(X).catch(()=>{})};return o.jsxs(o.Fragment,{children:[o.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:24},children:[o.jsx("h1",{className:"page-title",style:{marginBottom:0},children:"Agents"}),o.jsx("button",{className:"btn btn-primary",onClick:()=>h(!0),children:"+ Register Agent"})]}),b.length===0?o.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:"No agents registered. Register your first agent to get started."}):o.jsxs(o.Fragment,{children:[o.jsxs("table",{children:[o.jsx("thead",{children:o.jsxs("tr",{children:[o.jsx("th",{children:"Name"}),o.jsx("th",{children:"Client ID"}),o.jsx("th",{children:"Scopes"}),o.jsx("th",{children:"Grant Types"}),o.jsx("th",{children:"Created"})]})}),o.jsx("tbody",{children:b.map(E=>o.jsxs("tr",{onClick:()=>P(E),style:{cursor:"pointer"},children:[o.jsx("td",{style:{fontWeight:500},children:E.client_name}),o.jsxs("td",{className:"mono",style:{color:"var(--text-secondary)"},children:[E.client_id.substring(0,20),"..."]}),o.jsx("td",{children:(E.scope||"").split(" ").filter(Boolean).map(q=>o.jsx("span",{className:`badge badge-${q}`,style:{marginRight:4},children:q},q))}),o.jsx("td",{className:"mono",style:{color:"var(--text-secondary)",fontSize:12},children:(E.grant_types||[]).join(", ")}),o.jsx("td",{style:{color:"var(--text-secondary)"},children:new Date(E.created_at).toLocaleDateString()})]},E.client_id))})]}),o.jsxs("div",{style:{color:"var(--text-muted)",fontSize:13,marginTop:12},children:[b.length," agent",b.length!==1?"s":""," registered"]})]}),N&&o.jsx(h0,{onClose:()=>h(!1),onRegistered:E=>{h(!1),Y(E),j()}}),H&&o.jsx(y0,{credentials:H,onClose:()=>Y(null)}),x&&o.jsx(v0,{agent:x,onClose:()=>P(null)})]})}function h0({onClose:b,onRegistered:X}){const[N,h]=yl.useState(""),[H,Y]=yl.useState({read:!0,write:!1,admin:!1}),[x,P]=yl.useState(!1),[j,E]=yl.useState(""),q=async M=>{if(M.preventDefault(),!N.trim()){E("Name required");return}P(!0),E("");try{const K=Object.entries(H).filter(([,Ml])=>Ml).map(([Ml])=>Ml).join(" "),Tl=await fetch("/admin/api/register-client",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:N.trim(),scopes:K})});if(!Tl.ok)throw new Error("Registration failed");const Ol=await Tl.json();X({clientId:Ol.clientId,clientSecret:Ol.clientSecret,name:N.trim()})}catch(K){E(K instanceof Error?K.message:"Registration failed")}finally{P(!1)}};return o.jsx("div",{className:"modal-overlay",onClick:b,children:o.jsxs("form",{className:"modal",onClick:M=>M.stopPropagation(),onSubmit:q,children:[o.jsx("div",{className:"modal-title",children:"Register Agent"}),o.jsxs("div",{style:{marginBottom:16},children:[o.jsx("label",{children:"Agent Name"}),o.jsx("input",{placeholder:"e.g. perplexity-production",value:N,onChange:M=>h(M.target.value),autoFocus:!0})]}),o.jsxs("div",{style:{marginBottom:20},children:[o.jsx("label",{children:"Scopes"}),o.jsx("div",{className:"checkbox-group",children:["read","write","admin"].map(M=>o.jsxs("label",{className:"checkbox-label",children:[o.jsx("input",{type:"checkbox",checked:H[M],onChange:K=>Y(Tl=>({...Tl,[M]:K.target.checked}))}),M]},M))})]}),j&&o.jsx("div",{style:{color:"var(--error)",fontSize:13,marginBottom:12},children:j}),o.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end"},children:[o.jsx("button",{type:"button",className:"btn btn-secondary",onClick:b,children:"Cancel"}),o.jsx("button",{type:"submit",className:"btn btn-primary",disabled:x,children:x?"Registering...":"Register"})]})]})})}function y0({credentials:b,onClose:X}){const N=H=>navigator.clipboard.writeText(H),h=()=>{const H=new Blob([JSON.stringify(b,null,2)],{type:"application/json"}),Y=URL.createObjectURL(H),x=document.createElement("a");x.href=Y,x.download=`${b.name}-credentials.json`,x.click(),URL.revokeObjectURL(Y)};return o.jsx("div",{className:"modal-overlay",children:o.jsxs("div",{className:"modal",style:{maxWidth:560},children:[o.jsxs("div",{style:{textAlign:"center",marginBottom:16},children:[o.jsx("div",{style:{fontSize:36,color:"var(--success)",marginBottom:8},children:"✓"}),o.jsx("div",{style:{fontSize:20,fontWeight:600},children:"Agent Registered"})]}),o.jsxs("div",{style:{marginBottom:12},children:[o.jsx("label",{style:{fontSize:12},children:"Client ID"}),o.jsxs("div",{className:"code-block",children:[o.jsx("span",{children:b.clientId}),o.jsx("button",{className:"copy-btn",onClick:()=>N(b.clientId),children:"Copy"})]})]}),o.jsxs("div",{style:{marginBottom:12},children:[o.jsx("label",{style:{fontSize:12},children:"Client Secret"}),o.jsxs("div",{className:"code-block",children:[o.jsx("span",{children:b.clientSecret}),o.jsx("button",{className:"copy-btn",onClick:()=>N(b.clientSecret),children:"Copy"})]})]}),o.jsx("div",{className:"warning-bar",children:"Save this secret now. It will not be shown again."}),o.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end",marginTop:20},children:[o.jsx("button",{className:"btn btn-secondary",onClick:h,children:"Download as JSON"}),o.jsx("button",{className:"btn btn-primary",onClick:X,children:"Done"})]})]})})}function v0({agent:b,onClose:X}){const[N,h]=yl.useState("perplexity"),H=x=>navigator.clipboard.writeText(x),Y={perplexity:`URL: http://YOUR_SERVER/mcp +Client ID: ${b.client_id} + +Paste into Settings > Connectors`,claude:`claude mcp add gbrain \\ + -t http http://YOUR_SERVER/mcp \\ + --client-id ${b.client_id} \\ + --client-secret YOUR_SECRET`,json:JSON.stringify({client_id:b.client_id,client_name:b.client_name,scope:b.scope,grant_types:b.grant_types},null,2)};return o.jsxs(o.Fragment,{children:[o.jsx("div",{className:"drawer-overlay",onClick:X}),o.jsxs("div",{className:"drawer",children:[o.jsx("button",{className:"drawer-close",onClick:X,children:"✕"}),o.jsx("div",{style:{fontSize:18,fontWeight:600,marginBottom:4},children:b.client_name}),o.jsx("span",{className:"badge badge-success",children:"Active"}),o.jsx("div",{className:"section-title",children:"Details"}),o.jsxs("div",{style:{display:"grid",gridTemplateColumns:"100px 1fr",gap:"6px 12px",fontSize:13},children:[o.jsx("span",{style:{color:"var(--text-secondary)"},children:"Client ID"}),o.jsxs("span",{className:"mono",children:[b.client_id.substring(0,24),"..."]}),o.jsx("span",{style:{color:"var(--text-secondary)"},children:"Scopes"}),o.jsx("span",{children:(b.scope||"").split(" ").filter(Boolean).map(x=>o.jsx("span",{className:`badge badge-${x}`,style:{marginRight:4},children:x},x))}),o.jsx("span",{style:{color:"var(--text-secondary)"},children:"Registered"}),o.jsx("span",{children:new Date(b.created_at).toLocaleDateString()})]}),o.jsx("div",{className:"section-title",children:"Config Export"}),o.jsxs("div",{className:"tabs",children:[o.jsx("div",{className:`tab ${N==="perplexity"?"active":""}`,onClick:()=>h("perplexity"),children:"Perplexity"}),o.jsx("div",{className:`tab ${N==="claude"?"active":""}`,onClick:()=>h("claude"),children:"Claude Code"}),o.jsx("div",{className:`tab ${N==="json"?"active":""}`,onClick:()=>h("json"),children:"JSON"})]}),o.jsxs("div",{className:"code-block",children:[o.jsx("pre",{style:{whiteSpace:"pre-wrap",margin:0},children:Y[N]}),o.jsx("button",{className:"copy-btn",onClick:()=>H(Y[N]),children:"Copy"})]}),o.jsx("div",{style:{marginTop:32},children:o.jsx("button",{className:"btn btn-danger",children:"Revoke Agent"})})]})]})}function r0(){const[b,X]=yl.useState({rows:[],total:0,page:1,pages:1}),[N,h]=yl.useState(1);yl.useEffect(()=>{H(N)},[N]);const H=x=>{Qa.requests(x).then(X).catch(()=>{})},Y=x=>{const P=Date.now()-new Date(x).getTime();return P<6e4?`${Math.floor(P/1e3)}s ago`:P<36e5?`${Math.floor(P/6e4)} min ago`:P<864e5?`${Math.floor(P/36e5)}h ago`:new Date(x).toLocaleDateString()};return o.jsxs(o.Fragment,{children:[o.jsx("h1",{className:"page-title",children:"Request Log"}),b.rows.length===0?o.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:"No requests yet."}):o.jsxs(o.Fragment,{children:[o.jsxs("table",{children:[o.jsx("thead",{children:o.jsxs("tr",{children:[o.jsx("th",{children:"Time"}),o.jsx("th",{children:"Agent"}),o.jsx("th",{children:"Operation"}),o.jsx("th",{children:"Latency"}),o.jsx("th",{children:"Status"})]})}),o.jsx("tbody",{children:b.rows.map(x=>o.jsxs("tr",{children:[o.jsx("td",{style:{color:"var(--text-secondary)"},children:Y(x.created_at)}),o.jsx("td",{className:"mono",children:x.token_name||"unknown"}),o.jsx("td",{className:"mono",children:x.operation}),o.jsxs("td",{className:"mono",children:[x.latency_ms," ms"]}),o.jsx("td",{children:o.jsx("span",{className:`badge badge-${x.status}`,children:x.status})})]},x.id))})]}),o.jsxs("div",{className:"pagination",children:[o.jsxs("span",{children:["Page ",b.page," of ",b.pages," (",b.total," total)"]}),o.jsxs("div",{style:{display:"flex",gap:8},children:[o.jsx("button",{disabled:b.page<=1,onClick:()=>h(x=>x-1),children:"Previous"}),o.jsx("button",{disabled:b.page>=b.pages,onClick:()=>h(x=>x+1),children:"Next"})]})]})]})]})}function Om(){const b=window.location.hash.replace("#","")||"dashboard";return["login","dashboard","agents","log"].includes(b)?b:"dashboard"}function g0(){const[b,X]=yl.useState(Om);yl.useEffect(()=>{const h=()=>X(Om());return window.addEventListener("hashchange",h),()=>window.removeEventListener("hashchange",h)},[]);const N=h=>{window.location.hash=h,X(h)};return b==="login"?o.jsx(d0,{onLogin:()=>N("dashboard")}):o.jsxs("div",{className:"app",children:[o.jsxs("nav",{className:"sidebar",children:[o.jsx("div",{className:"sidebar-logo",children:"GBrain"}),o.jsxs("div",{className:"sidebar-nav",children:[o.jsx("a",{className:`nav-item ${b==="dashboard"?"active":""}`,onClick:()=>N("dashboard"),children:"Dashboard"}),o.jsx("a",{className:`nav-item ${b==="agents"?"active":""}`,onClick:()=>N("agents"),children:"Agents"}),o.jsx("a",{className:`nav-item ${b==="log"?"active":""}`,onClick:()=>N("log"),children:"Request Log"})]})]}),o.jsxs("main",{className:"main",children:[b==="dashboard"&&o.jsx(o0,{}),b==="agents"&&o.jsx(m0,{}),b==="log"&&o.jsx(r0,{})]})]})}f0.createRoot(document.getElementById("root")).render(o.jsx(l0.StrictMode,{children:o.jsx(g0,{})})); diff --git a/admin/dist/index.html b/admin/dist/index.html new file mode 100644 index 000000000..00c304322 --- /dev/null +++ b/admin/dist/index.html @@ -0,0 +1,16 @@ + + + + + + GBrain Admin + + + + + + + +
+ + diff --git a/llms-full.txt b/llms-full.txt index 0038f5f88..ee00ca24b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -226,9 +226,9 @@ strict behavior when unset. - `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP transports. Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1/F2/F3 drift bugs in the original v0.22.5 HTTP transport. - `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter. `buildDefaultLimiters()` returns the two-bucket pipeline: pre-auth IP (30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap bounds memory under attacker-controlled key growth. - `src/commands/serve-http.ts` (v0.26.0) — Express 5 HTTP MCP server with OAuth 2.1, admin dashboard, and SSE live activity feed. Started via `gbrain serve --http [--port N] [--token-ttl N] [--enable-dcr] [--public-url URL]`. Supersedes the v0.22.7 `src/mcp/http-transport.ts` simple bearer-auth path. Combines MCP SDK's `mcpAuthRouter` (authorize / token / register / revoke endpoints), a custom `client_credentials` handler (SDK's token endpoint throws `UnsupportedGrantTypeError` for CC; the custom handler runs BEFORE the router and falls through for `auth_code` / `refresh_token`), `requireBearerAuth` middleware for `/mcp` with scope enforcement before op dispatch, `localOnly` rejection, and `express-rate-limit` at 50 req / 15 min on `/token`. Serves the built admin SPA from `admin/dist/` with SPA fallback. `/admin/events` SSE endpoint broadcasts every MCP request to connected admin browsers. `cookie-parser` middleware wired (Express 5 has no built-in). Startup logging prints port, engine, configured issuer URL (honors `--public-url`), registered-client count, DCR status, and admin bootstrap token. -- `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch. +- `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch. **v0.26.2:** module-private `coerceTimestamp()` boundary helper at the top of the file normalizes postgres-driver-as-string BIGINT columns to JS numbers at every read site (5 call sites: `getClient` L112+L113 for DCR `/register` RFC 7591 §3.2.1 numeric timestamps, `exchangeRefreshToken` L274 + `verifyAccessToken` L296+L303 for the SDK's `typeof === 'number'` bearerAuth check). Throws on non-finite input (NaN/Infinity) so corrupt rows fail loud at the boundary instead of riding through as `expiresAt: NaN`; returns undefined for SQL NULL so callers decide NULL semantics explicitly (refresh + access token paths treat NULL as expired). Helper intentionally NOT promoted to `src/core/utils.ts` — codex review flagged repo-wide BIGINT precision-loss risk for a generic helper. - `admin/` (v0.26.0) — React 19 + Vite + TypeScript admin SPA embedded in the binary via `admin/dist/` served by `serve-http.ts`. 7 screens: Login (bootstrap token → session cookie), Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes + grant type selector), Credentials reveal (full-screen modal with Copy + Download JSON + yellow one-time-only warning), Request Log (filterable paginated), Agent Detail drawer (Details / Activity / Config Export tabs + Revoke). Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges. HTTP-only SameSite=Strict cookie auth. 65KB gzip. Build: `cd admin && bun install && bun run build`; output at `admin/dist/` is committed for self-contained binaries. -- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) for OAuth 2.1 client registration. Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. +- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) and `gbrain auth revoke-client ` (v0.26.2) for OAuth 2.1 client lifecycle. `revoke-client` runs an atomic `DELETE...RETURNING` on `oauth_clients`; FK `ON DELETE CASCADE` on `oauth_tokens.client_id` and `oauth_codes.client_id` purges every active token + authorization code in a single transaction. `process.exit(1)` on no-such-client (idempotent — re-running on the same id produces the same exit-1 message). Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration. - `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally. - `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry ` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed. - `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent. @@ -453,7 +453,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac `test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present), `test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics), `test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases), -`test/oauth.test.ts` (v0.26.0 OAuth 2.1 provider — 27 cases: register, getClient, `client_credentials` grant exchange, `authorization_code` flow with PKCE challenge / verifier, refresh token rotation, `verifyAccessToken` with both OAuth + legacy `access_tokens` fallback, `revokeToken`, `sweepExpiredTokens`, and a contract test asserting `scope` + `localOnly` annotations are set correctly on all 30 operations), +`test/oauth.test.ts` (v0.26.0 OAuth 2.1 provider — 27 cases: register, getClient, `client_credentials` grant exchange, `authorization_code` flow with PKCE challenge / verifier, refresh token rotation, `verifyAccessToken` with both OAuth + legacy `access_tokens` fallback, `revokeToken`, `sweepExpiredTokens`, and a contract test asserting `scope` + `localOnly` annotations are set correctly on all 30 operations; **v0.26.2** adds 5 `coerceTimestamp` unit cases (null/undefined/string/number/throw-on-NaN), NULL-`expires_at`-as-expired contract tests for both refresh + access token paths, and a cascade-delete contract test asserting `revoke-client` purges `oauth_tokens` + `oauth_codes` rows via FK CASCADE), `test/check-resolvable-cli.test.ts` (v0.19 CLI wrapper: exit codes, JSON envelope shape, AGENTS.md fallback chain), `test/regression-v0_16_4.test.ts` (findRepoRoot regression guard — hermetic startDir parameterization), `test/filing-audit.test.ts` (v0.19 Check 6: `writes_pages` / `writes_to` frontmatter, filing-rules JSON validation), @@ -480,6 +480,7 @@ E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_U - `test/e2e/engine-parity.test.ts` (v0.22.0) — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector`. Codex flagged that Postgres ranks pages then picks best chunk while PGLite returns chunks directly — without parity coverage the source-boost fix could pass on PGLite and fail on Postgres. Skips gracefully when `DATABASE_URL` is unset. - `test/e2e/postgres-bootstrap.test.ts` (v0.22.6.1) — exercises `PostgresEngine.initSchema()` directly against a fresh real Postgres database. Asserts the bootstrap path is no-op on fresh installs and that SCHEMA_SQL replays cleanly through the engine path (not via the standalone `db.initSchema` from `src/core/db.ts`, which would have produced false-positive coverage). Codex caught the E2E-shape gap during plan review. - `test/e2e/http-transport.test.ts` (v0.22.7) — 8 cases against real Postgres covering `gbrain serve --http` end-to-end: bearer auth round-trip, `last_used_at` SQL-level debounce semantics, `mcp_request_log` row insertion on success and auth_failed paths, `/health` DB-down → 503 (DB-probing health check), and the F1+F2+F3 dispatch round-trip with a real operation. Skips gracefully when `DATABASE_URL` is unset. +- `test/e2e/serve-http-oauth.test.ts` (v0.26.0, expanded v0.26.2) — real-Postgres E2E against `gbrain serve --http` with full OAuth 2.1. Spawns a subprocess server, registers a client via the CLI, mints `client_credentials` tokens, exercises the `/mcp` JSON-RPC pipeline. **v0.26.2 adds:** real DCR `/register` HTTP-level response-shape test (asserts `typeof body.client_id_issued_at === 'number'` over the wire — RFC 7591 §3.2.1 spec compliance, not just internal-store shape); real CLI subprocess test for `revoke-client` (registers → mints token → revokes via `execSync` → asserts token rejected at `/mcp` → asserts re-run exits 1); server fixture flips on `--enable-dcr` so `/register` is reachable. **bun execSync env-inheritance fix:** bun's `execSync` does NOT inherit env mutations done via `process.env.X = ...`, only OS-level env from before bun started. helpers.ts loads `.env.testing` and sets `DATABASE_URL` via `process.env` mutation, which is invisible to subprocesses unless `env: { ...process.env }` is passed explicitly — every subprocess call in this file passes `env: { ...process.env }` for that reason. Reference fix for the next maintainer hitting the same failure mode in sibling sync/cycle/dream/claw-test E2Es. `afterAll` cleanup is guarded on `clientId` (won't throw if `beforeAll` failed before registration); cleanup errors surface to stderr without throwing so real test failures aren't masked. Tracks DCR-registered clients alongside the manual one. Skips gracefully when `DATABASE_URL` is unset. - `test/e2e/sync-parallel.test.ts` (v0.22.13 PR #490) — DATABASE_URL-gated. T2: 60-file Postgres sync at concurrency=4 imports all + no connection leak (probes `pg_stat_activity` before/after to confirm worker engines disconnected). P4: 120-file serial-vs-parallel benchmark prints `SYNC_PARALLEL_BENCH N files | serial=Xms | parallel(4)=Yms | speedup=Zx` for CHANGELOG quoting. Asserts parallel ≤ serial × 1.5 (CI-noise tolerant; not a strict speedup gate). - Tier 2 (`skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI - If `.env.testing` doesn't exist in this directory, check sibling worktrees for one: @@ -591,6 +592,40 @@ For single long-running queries, use `startHeartbeat(reporter, note)` with a try/finally to guarantee cleanup. Never call `process.stdout.write('\r...')` in bulk paths, the CI guard will fail the build. +## Capturing test output (NEVER pipe through `tail` / `head`) + +**Iron rule:** when running `bun test`, `bun run test:e2e`, `bun run typecheck`, +or any other test/check command, redirect to a file FIRST, then `tail` the file +separately: + +```bash +# RIGHT — full output preserved, real exit code visible +bun test > /tmp/ship_units.txt 2>&1 +echo "EXIT=$?" +tail -50 /tmp/ship_units.txt +grep -E '(fail\)|✗|error:' /tmp/ship_units.txt | head -30 +``` + +```bash +# WRONG — exit code is `tail`'s (always 0), failures truncated, ship gates fail open +bun test 2>&1 | tail -10 +``` + +The pipe form silently breaks /ship Step T1 (test failure ownership triage) and +the test verification gate (Step 16) because: +- `$?` after a pipe is the LAST command's exit code (`tail` → 0), not bun's +- bun prints failure details before the summary line, so `tail -N` drops them +- Step T1 needs the full failure list to classify in-branch vs pre-existing + +This bit us during v0.26.2 ship: `bun test 2>&1 | tail -10` reported "3911 pass / 23 fail" +but no failure details survived, forcing a 23-minute re-run to triage. + +Apply the same pattern to any long-running command whose exit code matters: +`bun run typecheck`, `bun run ci:local`, migration runs, eval suites, etc. +For background tasks (`run_in_background: true`), the harness captures the exit +file separately — use it via the bg task's `.exit` file, not the streamed +output. + ## Build `bun build --compile --outfile bin/gbrain src/cli.ts` @@ -2172,6 +2207,8 @@ ADMIN gbrain auth register-client Register an OAuth 2.1 client --grant-types client_credentials,authorization_code --scopes "read write admin" + gbrain auth revoke-client Revoke an OAuth 2.1 client (cascade purges + active tokens + auth codes via FK CASCADE) # OAuth 2.1 clients can also be registered from the /admin dashboard or # programmatically via oauthProvider.registerClientManual() for host-repo wrappers. gbrain integrations Integration recipe dashboard diff --git a/package.json b/package.json index 45e98a730..fa511eb77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.26.0", + "version": "0.26.2", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 4ccff8bfc..e025455c2 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -225,6 +225,34 @@ async function test(url: string, token: string) { console.log(`\n🧠 Your brain is live! (${elapsed}s)`); } +async function revokeClient(clientId: string) { + if (!clientId) { + console.error('Usage: auth revoke-client '); + process.exit(1); + } + const sql = postgres(getDatabaseUrl(true)!); + try { + // Atomic single-statement delete: no race window between count + delete. + // Postgres cascades to oauth_tokens and oauth_codes (FK ON DELETE CASCADE + // declared in src/schema.sql:370,382) before the transaction commits. + const rows = await sql` + DELETE FROM oauth_clients WHERE client_id = ${clientId} + RETURNING client_id, client_name + `; + if (rows.length === 0) { + console.error(`No client found with id "${clientId}"`); + process.exit(1); + } + console.log(`OAuth client revoked: "${rows[0].client_name}" (${clientId})`); + console.log('Tokens and authorization codes purged via cascade.'); + } catch (e: any) { + console.error('Error:', e.message); + process.exit(1); + } finally { + await sql.end(); + } +} + async function registerClient(name: string, args: string[]) { if (!name) { console.error('Usage: auth register-client [--grant-types G] [--scopes S]'); process.exit(1); } const grantsIdx = args.indexOf('--grant-types'); @@ -268,6 +296,7 @@ export async function runAuth(args: string[]): Promise { case 'list': await list(); return; case 'revoke': await revoke(rest[0]); return; case 'register-client': await registerClient(rest[0], rest.slice(1)); return; + case 'revoke-client': await revokeClient(rest[0]); return; case 'test': { const tokenIdx = rest.indexOf('--token'); const url = rest.find(a => !a.startsWith('--') && a !== rest[tokenIdx + 1]); @@ -285,6 +314,7 @@ Usage: gbrain auth register-client [options] Register an OAuth 2.1 client --grant-types (default: client_credentials) --scopes "" (default: read) + gbrain auth revoke-client Hard-delete an OAuth 2.1 client (cascades to tokens + codes) gbrain auth test --token Smoke-test a remote MCP server `); } diff --git a/src/core/oauth-provider.ts b/src/core/oauth-provider.ts index 67f1fca8d..fb418237a 100644 --- a/src/core/oauth-provider.ts +++ b/src/core/oauth-provider.ts @@ -77,6 +77,34 @@ function validateRedirectUri(uri: string): void { ); } +/** + * Coerce an OAuth timestamp column (Unix epoch seconds, BIGINT) into a JS + * number, or undefined for SQL NULL. + * + * Why this exists: postgres.js with `prepare: false` (the auto-detected setting + * on Supabase PgBouncer / port 6543; see src/core/db.ts:resolvePrepare) returns + * BIGINT columns as strings. Two surfaces break on that: (1) the MCP SDK's + * bearerAuth middleware checks `typeof authInfo.expiresAt === 'number'` and + * rejects strings; (2) RFC 7591 §3.2.1 requires `client_id_issued_at` and + * `client_secret_expires_at` to be JSON numbers in DCR responses, not strings. + * + * Throws on non-finite (NaN/Infinity) so corrupt rows fail loud at the boundary + * instead of letting `expiresAt: NaN` flow through to the SDK as a fake-valid + * token. Returns undefined for SQL NULL so callers decide NULL semantics + * explicitly. For OAuth, the comparison sites treat NULL as "expired" + * (fail-closed); the DCR response sites preserve undefined per RFC 7591 + * (the `client_secret_expires_at` field is optional, undefined means + * "did not expire"). + */ +export function coerceTimestamp(value: unknown): number | undefined { + if (value === null || value === undefined) return undefined; + const n = Number(value); + if (!Number.isFinite(n)) { + throw new Error(`coerceTimestamp: non-finite timestamp value ${JSON.stringify(value)}`); + } + return n; +} + interface GBrainOAuthProviderOptions { sql: SqlQuery; /** Default token TTL in seconds (default: 3600 = 1 hour) */ @@ -109,8 +137,8 @@ class GBrainClientsStore implements OAuthRegisteredClientsStore { grant_types: (r.grant_types as string[]) || ['client_credentials'], scope: r.scope as string | undefined, token_endpoint_auth_method: r.token_endpoint_auth_method as string | undefined, - client_id_issued_at: r.client_id_issued_at as number | undefined, - client_secret_expires_at: r.client_secret_expires_at as number | undefined, + client_id_issued_at: coerceTimestamp(r.client_id_issued_at), + client_secret_expires_at: coerceTimestamp(r.client_secret_expires_at), }; } @@ -271,7 +299,11 @@ export class GBrainOAuthProvider implements OAuthServerProvider { const row = rows[0]; if (row.client_id !== client.client_id) throw new Error('Client mismatch'); - if ((row.expires_at as number) < now) throw new Error('Refresh token expired'); + // NULL expires_at is treated as expired (fail-closed). Schema permits NULL + // even though issueTokens always sets it, so a corrupt or hand-modified row + // can't ride past validation. + const expiresAt = coerceTimestamp(row.expires_at); + if (expiresAt === undefined || expiresAt < now) throw new Error('Refresh token expired'); const tokenScopes = scopes || (row.scopes as string[]) || []; return this.issueTokens(client.client_id, tokenScopes, resource, true); @@ -293,14 +325,18 @@ export class GBrainOAuthProvider implements OAuthServerProvider { if (oauthRows.length > 0) { const row = oauthRows[0]; - if ((row.expires_at as number) < now) { + // NULL expires_at is treated as expired (fail-closed). Schema permits NULL, + // and the SDK's bearerAuth requires `typeof expiresAt === 'number'` — we + // throw here rather than return an undefined-bearing AuthInfo. + const expiresAt = coerceTimestamp(row.expires_at); + if (expiresAt === undefined || expiresAt < now) { throw new Error('Token expired'); } return { token, clientId: row.client_id as string, scopes: (row.scopes as string[]) || [], - expiresAt: Number(row.expires_at), + expiresAt, resource: row.resource ? new URL(row.resource as string) : undefined, }; } diff --git a/test/e2e/serve-http-oauth.test.ts b/test/e2e/serve-http-oauth.test.ts index aabbd62e6..1fbfe2c62 100644 --- a/test/e2e/serve-http-oauth.test.ts +++ b/test/e2e/serve-http-oauth.test.ts @@ -25,18 +25,27 @@ if (skip) { const PORT = 19131; // Avoid collision with production 3131 const BASE = `http://localhost:${PORT}`; -describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { +describeE2E('serve-http OAuth 2.1 E2E (v0.26.1 + v0.26.2)', () => { let serverProcess: ReturnType | null = null; - let clientId: string; - let clientSecret: string; + let clientId: string | undefined; + let clientSecret: string | undefined; + // DCR-registered clients accumulate here so afterAll can revoke them too + // (one per test that posts to /register). + const dcrClientIds: string[] = []; beforeAll(async () => { const { execSync, spawn } = await import('child_process'); - // Register a test OAuth client via CLI + // Register a test OAuth client via CLI. + // env: { ...process.env } is required: bun's execSync does NOT inherit + // env mutations done via `process.env.X = ...` (only OS-level env from + // before bun started). helpers.ts loads .env.testing and sets DATABASE_URL + // via process.env mutation, which is invisible to subprocesses unless we + // explicitly re-pass process.env. Same pattern applies to every execSync + // in this file. const regOutput = execSync( 'bun run src/cli.ts auth register-client e2e-oauth-test --grant-types client_credentials --scopes "read write"', - { cwd: process.cwd(), encoding: 'utf8' } + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } } ); const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/); const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/); @@ -44,11 +53,13 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { clientId = idMatch[1]; clientSecret = secretMatch[1]; - // Start the HTTP server + // Start the HTTP server. v0.26.2 adds --enable-dcr so the /register + // endpoint is reachable for the DCR response-shape test. serverProcess = spawn('bun', [ 'run', 'src/cli.ts', 'serve', '--http', '--port', String(PORT), '--public-url', `http://localhost:${PORT}`, + '--enable-dcr', ], { cwd: process.cwd(), env: process.env, @@ -72,18 +83,28 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { }, 30_000); afterAll(async () => { - // Kill server + // Kill server first so it can't issue more tokens during cleanup. if (serverProcess) { serverProcess.kill('SIGTERM'); await new Promise(r => setTimeout(r, 1000)); if (!serverProcess.killed) serverProcess.kill('SIGKILL'); } - // Revoke test client - try { - const { execSync } = await import('child_process'); - execSync(`bun run src/cli.ts auth revoke-client "${clientId}"`, - { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe' }); - } catch {} + // v0.26.2 cleanup contract: only revoke if registration succeeded + // (clientId guard) and surface any cleanup failure to stderr without + // throwing — a real test failure is more interesting than the cleanup + // error that follows it. Same shape applies to DCR-registered clients + // tracked in dcrClientIds. + const { execSync } = await import('child_process'); + const toRevoke = [...(clientId ? [clientId] : []), ...dcrClientIds]; + for (const id of toRevoke) { + try { + execSync(`bun run src/cli.ts auth revoke-client "${id}"`, + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }); + } catch (e: any) { + // eslint-disable-next-line no-console + console.error(`[afterAll] revoke-client cleanup failed for ${id}: ${e.message}`); + } + } }); // Helper: mint a token with given scopes @@ -259,7 +280,11 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { const data = await res.json() as any; expect(data.status).toBe('ok'); expect(data.version).toBeDefined(); - expect(data.page_count).toBeGreaterThan(0); + // page_count: the endpoint must return a non-negative integer. The exact + // value depends on the deployment's brain state and is not what this test + // is checking — pre-v0.26.2 this asserted `> 0` and broke on fresh schemas. + expect(typeof data.page_count).toBe('number'); + expect(data.page_count).toBeGreaterThanOrEqual(0); }); // ========================================================================= @@ -288,4 +313,120 @@ describeE2E('serve-http OAuth 2.1 E2E (v0.26.1)', () => { const data = await res.json() as any; expect(data.error).toBe('invalid_grant'); }); + + // ========================================================================= + // v0.26.2: DCR /register response shape (RFC 7591 §3.2.1 number contract) + // ========================================================================= + // + // The user-visible bug v0.26.2 protects against: postgres.js with + // `prepare: false` returns BIGINT columns as strings, and an RFC-strict + // DCR client (Claude Code, Cursor) parses the /register response as JSON + // and rejects timestamps that aren't numbers. This is the HTTP-level test; + // the internal-store shape test in test/oauth.test.ts is not enough on its + // own (Codex flagged it as the wrong seam). + + test('DCR /register returns numeric client_id_issued_at (RFC 7591 §3.2.1)', async () => { + const res = await fetch(`${BASE}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'e2e-dcr-shape', + redirect_uris: ['https://example.com/cb'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'client_secret_basic', + scope: 'read', + }), + }); + expect(res.ok).toBe(true); + const body = await res.json() as any; + + // Track for cleanup before any assertion that could throw. + if (body.client_id) dcrClientIds.push(body.client_id); + + // The contract: client_id_issued_at is REQUIRED to be a JSON number per + // RFC 7591. Pre-v0.26.2 with prepare:false returned this as a string + // (e.g., "1735689600") and strict clients rejected the registration. + expect(typeof body.client_id_issued_at).toBe('number'); + expect(Number.isFinite(body.client_id_issued_at)).toBe(true); + expect(body.client_id_issued_at).toBeGreaterThan(0); + + // client_secret_expires_at is OPTIONAL. If present, it must also be a + // number. Undefined/missing means "does not expire" per the spec. + if (body.client_secret_expires_at !== undefined) { + expect(typeof body.client_secret_expires_at).toBe('number'); + expect(Number.isFinite(body.client_secret_expires_at)).toBe(true); + } + }, 15_000); + + // ========================================================================= + // v0.26.2: revoke-client CLI subprocess test + // ========================================================================= + // + // Validates the actual CLI router in src/commands/auth.ts, not just the + // database deletion semantics. Codex flagged that a unit test in + // test/oauth.test.ts proves DB DELETE works but does NOT prove the + // subcommand exists or routes correctly. + + test('auth revoke-client (CLI) deletes client + cascades to tokens', async () => { + const { execSync } = await import('child_process'); + + // Step 1: register a throwaway client via CLI. + // env: { ...process.env } per the bun execSync inheritance fix above. + const regOutput = execSync( + 'bun run src/cli.ts auth register-client e2e-revoke-cli --grant-types client_credentials --scopes read', + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } } + ); + const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/); + const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/); + expect(idMatch).not.toBeNull(); + expect(secretMatch).not.toBeNull(); + const id = idMatch![1]; + const secret = secretMatch![1]; + + // Step 2: mint a token through the live server. + const tokenRes = await fetch(`${BASE}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `grant_type=client_credentials&client_id=${id}&client_secret=${secret}&scope=read`, + }); + expect(tokenRes.ok).toBe(true); + const { access_token } = await tokenRes.json() as any; + + // Sanity: the freshly-minted token works at /mcp. + const before = await mcpCall(access_token, 'tools/list'); + expect(before.status).not.toBe(401); + + // Step 3: revoke via the CLI subprocess. + const revokeOutput = execSync( + `bun run src/cli.ts auth revoke-client "${id}"`, + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } } + ); + // The handler prints the human confirmation lines. No exit code != 0 + // here since execSync would throw. + expect(revokeOutput).toMatch(/OAuth client revoked/); + expect(revokeOutput).toMatch(/cascade/i); + + // Step 4: previously-minted token must now be rejected at /mcp. Cascade + // wiped the oauth_tokens row; verifyAccessToken throws "Invalid token". + // Match the existing pattern at line 156: SDK error mapping varies + // (401/403/500), so we assert non-success status + non-success body + // rather than a single status code. + const after = await mcpCall(access_token, 'tools/list'); + expect(after.status).toBeGreaterThanOrEqual(400); + const afterBody = await after.text(); + expect(afterBody).not.toContain('"tools":['); + + // Step 5: re-running revoke-client on the now-deleted id must exit 1. + let secondRunFailed = false; + let secondRunStderr = ''; + try { + execSync(`bun run src/cli.ts auth revoke-client "${id}"`, + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }); + } catch (e: any) { + secondRunFailed = true; + secondRunStderr = (e.stderr || '').toString() + (e.stdout || '').toString(); + } + expect(secondRunFailed).toBe(true); + expect(secondRunStderr).toMatch(/No client found/); + }, 30_000); }); diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 8870462d0..3977f516a 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { PGlite } from '@electric-sql/pglite'; import { vector } from '@electric-sql/pglite/vector'; import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; -import { GBrainOAuthProvider } from '../src/core/oauth-provider.ts'; +import { GBrainOAuthProvider, coerceTimestamp } from '../src/core/oauth-provider.ts'; import { hashToken, generateToken } from '../src/core/utils.ts'; import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts'; @@ -62,6 +62,43 @@ describe('generateToken', () => { }); }); +// --------------------------------------------------------------------------- +// coerceTimestamp — postgres BIGINT-as-string boundary helper +// --------------------------------------------------------------------------- + +describe('coerceTimestamp', () => { + test('null returns undefined', () => { + expect(coerceTimestamp(null)).toBeUndefined(); + }); + + test('undefined returns undefined', () => { + expect(coerceTimestamp(undefined)).toBeUndefined(); + }); + + test('numeric string coerces to number', () => { + // The actual production path: postgres-js with prepare:false returns + // BIGINT columns as strings. + expect(coerceTimestamp('12345')).toBe(12345); + expect(coerceTimestamp('1735689600')).toBe(1735689600); + }); + + test('native number passes through', () => { + // Direct-PG users on prepare:true get native numbers. + expect(coerceTimestamp(12345)).toBe(12345); + expect(coerceTimestamp(0)).toBe(0); + }); + + test('non-finite input throws (fail-closed contract)', () => { + // The load-bearing change vs Number(): corrupt rows fail loud at the + // boundary instead of letting NaN flow through to the SDK as a + // fake-valid `expiresAt`. + expect(() => coerceTimestamp('not-a-number')).toThrow(/non-finite/); + expect(() => coerceTimestamp(NaN)).toThrow(/non-finite/); + expect(() => coerceTimestamp(Infinity)).toThrow(/non-finite/); + expect(() => coerceTimestamp(-Infinity)).toThrow(/non-finite/); + }); +}); + // --------------------------------------------------------------------------- // Client Registration // --------------------------------------------------------------------------- @@ -180,6 +217,34 @@ describe('verifyAccessToken', () => { await expect(provider.verifyAccessToken('nonexistent-token')).rejects.toThrow('Invalid token'); }); + test('NULL expires_at is treated as expired (fail-closed)', async () => { + // Schema declares oauth_tokens.expires_at as nullable BIGINT (schema.sql:372). + // Hand-modified or corrupt rows could land with NULL; verifyAccessToken must + // fail-closed, not return an undefined-bearing AuthInfo that the SDK accepts. + const nullExpiryToken = generateToken('gbrain_at_'); + const hash = hashToken(nullExpiryToken); + const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0]; + await sql` + INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at) + VALUES (${hash}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${null}) + `; + await expect(provider.verifyAccessToken(nullExpiryToken)).rejects.toThrow('expired'); + }); + + test('cascade-deleted client invalidates its tokens (Invalid token, not Expired)', async () => { + // revoke-client does DELETE FROM oauth_clients WHERE client_id = ... + // The schema-level FK cascade (schema.sql:370) wipes oauth_tokens too. + // verifyAccessToken on a previously-minted token from that client must + // fail with "Invalid token" (cascade purged the row) — distinct from + // "Token expired" so logs distinguish the failure modes. + const { clientId, clientSecret } = await provider.registerClientManual( + 'cascade-test', ['client_credentials'], 'read', + ); + const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read'); + await sql`DELETE FROM oauth_clients WHERE client_id = ${clientId}`; + await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow('Invalid token'); + }); + test('expiresAt is always a number (not string) — SDK bearerAuth compat', async () => { // Regression: postgres driver with prepare:false returns integers as strings. // MCP SDK's bearerAuth middleware checks typeof === 'number' and rejects strings.