mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 20:50:34 +00:00
v0.26.2 fix(oauth): bun execSync env inheritance + BIGINT-as-string bug class (#593)
* feat(oauth): add coerceTimestamp helper + fix BIGINT-as-string bug class
Postgres-js with prepare:false (auto-detected on Supabase pooler / port
6543) returns BIGINT columns as strings. Two surfaces broke on this:
(1) MCP SDK's bearerAuth checks typeof === 'number' and rejected
strings — fixed in v0.26.1 only at line 303 of oauth-provider.ts;
(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 — latent until v0.26.2.
Adds module-private coerceTimestamp() at the SELECT-row → JS-number
boundary. Throws on non-finite (corrupt rows fail loud, not as
fake-valid expiresAt: NaN flowing into the SDK). Returns undefined for
SQL NULL — schema permits NULL on oauth_tokens.expires_at, callers
treat NULL as expired (fail-closed) at comparison sites and preserve
undefined in DCR getClient response per RFC 7591.
Refactors 5 sites:
- L112,113 (getClient) — DCR response numeric-shape compliance.
- L274 (exchangeRefreshToken) — NULL→expired fail-closed contract.
- L296,303 (verifyAccessToken) — single guard, narrowed return.
No `!` non-null assertions: all 5 sites read nullable BIGINT columns
per src/schema.sql:362,363,372. The L296/L303 cleanup also folds in
v0.26.1's inline Number(...) at L303.
* feat(auth): add gbrain auth revoke-client subcommand
Hard-deletes the matching oauth_clients row via atomic
DELETE ... RETURNING. Schema-level FK CASCADE on oauth_tokens.client_id
and oauth_codes.client_id (src/schema.sql:370,382) purges all dependent
rows in the same transaction. No manual delete of dependents needed.
Exit 1 on no-such-client (idempotent: re-running on the same id
produces the same error). Operator-friendly output: prints the client
name + cascade confirmation, no race-prone pre-delete count.
Closes the v0.26.1 process miss where test/e2e/serve-http-oauth.test.ts
afterAll already called this subcommand — silently failing because the
subcommand didn't exist. With this fix, E2E cleanup actually purges
test clients.
* test(oauth): v0.26.2 regression coverage + bun execSync env fix
Unit additions in test/oauth.test.ts:
- 5 cases pinning coerceTimestamp contract (null/undef/string/number/
throws-on-NaN). The throws-on-NaN case is load-bearing: pre-v0.26.2
Number(corrupt) → NaN, NaN < now is false → expired check skipped,
fake-valid expiresAt:NaN flowed to SDK. Now fail-closed.
- NULL expires_at on oauth_tokens insert → verifyAccessToken throws
"Token expired". Schema permits NULL; pre-v0.26.2 hand-modified rows
could ride past validation.
- Cascade-deleted client → previously-minted token fails
verifyAccessToken with "Invalid token" (not "expired"). Pins the
cascade contract independently of the CLI subprocess path.
E2E additions in test/e2e/serve-http-oauth.test.ts:
- DCR /register HTTP-level response-shape test. Spawns server with
--enable-dcr, POSTs a client manifest, asserts typeof === 'number'
on client_id_issued_at and (when present) client_secret_expires_at
per RFC 7591 §3.2.1. Replaces the v0.26.1 plan's internal-store-only
test that Codex flagged as the wrong seam.
- Real CLI subprocess test for revoke-client: register → mint token →
revoke via execSync → assert token rejected at /mcp + cascade
invalidation visible + re-run exits 1 with "No client found".
- afterAll guards on clientId so pre-registration beforeAll failures
surface cleanly instead of throwing on undefined during cleanup.
Also tracks DCR-registered clients alongside the manual one.
- Server fixture: --enable-dcr added so /register is reachable.
- Health endpoint: page_count assertion loosened from > 0 to >= 0
+ typeof number — pre-v0.26.2 broke on fresh-schema E2E runs.
bun execSync env-inheritance fix (the load-bearing infrastructure
fix that unbroke v0.26.2's full-suite test):
- bun's child_process.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, invisible to subprocesses unless env: { ...process.env }
is passed explicitly.
- All 4 execSync calls in this file (beforeAll register-client,
afterAll revoke-client, in-test register-client, in-test
revoke-client x2) now pass env: { ...process.env }.
- Without this, full bun test suite OAuth E2E fails with "Set
DATABASE_URL or GBRAIN_DATABASE_URL environment variable" even when
isolated test/e2e/serve-http-oauth.test.ts runs pass. Pattern is
documented inline as a reference for other E2E test fixes (see
TODOS.md "test infra (v0.26.2 follow-up)" for the 22-test backlog).
* build: commit admin/dist + remove gitignore exclusion
CLAUDE.md (admin/ section, v0.26.0 release notes) states:
"output at admin/dist/ is committed for self-contained binaries"
But .gitignore excluded admin/dist/, so the bun --compile binary that
embeds the admin SPA via `import path from '...' with { type: 'file' }`
couldn't resolve in fresh clones. PR #577 (v0.26.1) didn't catch this
because admin tests pass when admin/dist exists locally.
Removes the .gitignore line + commits the current 220KB build:
- index.html (0.7KB)
- assets/index-{hash}.js (210KB / 65KB gzip)
- assets/index-{hash}.css (6.3KB / 1.8KB gzip)
Now `bun build --compile --outfile bin/gbrain src/cli.ts` works on a
fresh clone without a separate `cd admin && bun install && bun run
build` step in CI.
* docs: capturing test output rule + regen llms-full.txt
Adds a CLAUDE.md section "Capturing test output (NEVER pipe through
tail / head)" documenting the iron rule that bit v0.26.2's ship:
bun test 2>&1 | tail -10 → exit code = tail's (always 0),
failures truncated, ship gates fail open
The pipe form silently breaks /ship Step T1 (test failure ownership
triage) because $? after a pipe is the LAST command's exit code, and
bun prints failure details before the summary line so tail -N drops
them. v0.26.2's first ship attempt reported "3911 pass / 23 fail" but
no failure details survived, forcing a 23-minute re-run to triage.
Right pattern: redirect to a file first, then tail the file separately.
Regenerates llms-full.txt to match the new CLAUDE.md content (drift
guard at test/build-llms.test.ts enforces this).
* docs: P0 TODO for 22 pre-existing test failures unrelated to OAuth
Captures the test-infra backlog uncovered by v0.26.2's full bun test
run. None of the 22 failing cases touch the OAuth diff:
- 12 Git-to-DB Sync Pipeline cases (state-machine drift)
- 3 multi-source cascade + sync routing cases
- E2E sync-parallel, sync --skip-failed, doctor, dream, runCycle,
claw-test fresh-install, BrainRegistry lazy init
Likely root causes for several: same bun execSync env-inheritance
pattern fixed in test/e2e/serve-http-oauth.test.ts during v0.26.2
(documented in the TODO + the inline test comment for the next
maintainer to find).
Separating from v0.26.2 keeps the OAuth ship focused on the bug
class it was scoped for. Fix-wave deserves its own PR.
* chore: bump to v0.26.2 + CHANGELOG
VERSION 0.26.0 → 0.26.2. Includes a retroactive v0.26.1 entry above
v0.26.0 because PR #577 shipped its three fixes (oauth-provider:303
Number cast, OAuth metadata interceptor, Express 5 trust proxy +
admin wildcard) without bumping VERSION/package.json/CHANGELOG —
this branch catches the changelog up to commit history.
v0.26.2 release-summary covers the OAuth string-vs-number bug class
fix (5 sites + coerceTimestamp helper), the gbrain auth revoke-client
subcommand landing as a real CLI, and the bun execSync env-inheritance
fix that unblocked full-suite E2E OAuth tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: post-ship updates for v0.26.2
- CLAUDE.md src/core/oauth-provider.ts: append v0.26.2 coerceTimestamp boundary helper note (5 call sites, NULL semantics, throw-on-NaN posture, intentionally module-private)
- CLAUDE.md src/commands/auth.ts: add v0.26.2 revoke-client subcommand with FK CASCADE cleanup
- CLAUDE.md test/oauth.test.ts: bump v0.26.2 case additions (5 coerceTimestamp + NULL-expires_at + cascade-delete contract)
- CLAUDE.md test/e2e/serve-http-oauth.test.ts: new entry covering v0.26.0 + v0.26.2 expansion (DCR HTTP-level test, CLI subprocess revoke-client test, bun execSync env-inheritance fix as reference for sibling E2Es)
- README.md: add gbrain auth revoke-client to command list
- llms-full.txt: regenerate after CLAUDE.md edits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d01a921e01
commit
1055e10c23
+4
-1
@@ -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/
|
||||
|
||||
+101
@@ -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 <client_id>` 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 <client_id>` 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 <client_id>`** 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.**
|
||||
|
||||
@@ -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 <client_id>` (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 <version>` 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 `<id>.exit` file, not the streamed
|
||||
output.
|
||||
|
||||
## Build
|
||||
|
||||
`bun build --compile --outfile bin/gbrain src/cli.ts`
|
||||
|
||||
@@ -734,6 +734,8 @@ ADMIN
|
||||
gbrain auth register-client <name> Register an OAuth 2.1 client
|
||||
--grant-types client_credentials,authorization_code
|
||||
--scopes "read write admin"
|
||||
gbrain auth revoke-client <client_id> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+55
File diff suppressed because one or more lines are too long
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GBrain Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="/admin/assets/index-BYirrLlW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-0QYnbXj9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+40
-3
@@ -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 <client_id>` (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 <version>` 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 `<id>.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 <name> Register an OAuth 2.1 client
|
||||
--grant-types client_credentials,authorization_code
|
||||
--scopes "read write admin"
|
||||
gbrain auth revoke-client <client_id> 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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 <client_id>');
|
||||
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 <name> [--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<void> {
|
||||
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 <name> [options] Register an OAuth 2.1 client
|
||||
--grant-types <client_credentials,authorization_code> (default: client_credentials)
|
||||
--scopes "<read write admin>" (default: read)
|
||||
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
|
||||
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof import('child_process').spawn> | 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);
|
||||
});
|
||||
|
||||
+66
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user