Files
gbrain/test/migrate.test.ts
T
f0825018dd v0.26.3 feat(admin): observability + per-agent config + auth hardening (#586)
* feat(admin): legacy API keys alongside OAuth clients in dashboard

Adds API key management to the admin dashboard:

Server (serve-http.ts):
- GET /admin/api/api-keys — list legacy access_tokens with status
- POST /admin/api/api-keys — create new bearer token
- POST /admin/api/api-keys/revoke — revoke by name
- Stats endpoint now includes active_api_keys count

Admin UI (Agents.tsx):
- Tabbed view: 'OAuth Clients' | 'API Keys'
- API Keys tab: table with name, status, created, last used, revoke button
- Create API Key modal with name input
- Token reveal modal with copy button + warning
- Badge showing active key count on tab

Both auth methods (OAuth 2.1 client_credentials and legacy bearer tokens)
now visible and manageable from a single admin surface.

* feat(admin): remember admin token in localStorage + auto-reauth

Login flow:
- First login: paste token, saved to localStorage
- Subsequent visits: auto-login from localStorage (no paste needed)
- Shows 'Authenticating...' spinner during auto-login
- If saved token is stale (server restarted), clears it and shows login form

Session recovery:
- If session cookie expires mid-use (server restart, 24h expiry), the API
  layer auto-reauths with the saved token before redirecting to login
- Transparent to the user — one failed request triggers reauth + retry
- Only falls back to login page if the saved token itself is invalid

Security:
- Token stored in localStorage (same-origin, tailnet-only deployment)
- Cleared automatically when token becomes invalid
- Cookie remains HttpOnly + SameSite=Strict for the actual session

* feat(admin): rich request logging + agent activity tracking

Server:
- mcp_request_log now captures params (jsonb) and error_message (text)
- Agents API returns last_used_at, total_requests, requests_today
- Request log API supports agent/operation/status filtering via query params
- SSE broadcast includes params and error details

Agents page:
- Shows 'Requests today / total' and 'Last used' (relative time) per agent
- Removed Client ID column (low signal, shown in drawer)

Request Log page:
- New 'Params' column — shows query text, slug, or param count inline
- Click any row to expand full details (params JSON, error message, timestamps)
- Click agent name to filter all requests by that agent
- Agent filter dropdown in header
- Error messages shown in red in expanded view

What this means: when Claude Code searches for 'pedro franceschi',
the admin dashboard shows the search query, which agent ran it,
how long it took, and whether it succeeded — all clickable.

* feat(admin): magic link login — ask your agent for the URL

New flow:
1. User opens /admin → sees 'This is a protected dashboard'
2. UI tells them: 'Ask your AI agent for the admin login link'
3. Agent generates: https://host:port/admin/auth/<token>
4. User clicks the link → auto-authenticates → redirects to dashboard
5. Session lasts 7 days (magic link) vs 24h (manual token paste)

Server: GET /admin/auth/:token validates the bootstrap token, sets
HttpOnly cookie, redirects to /admin/. Invalid tokens get a plain
text error telling them to ask their agent for a fresh link.

Login page: primary UX is the 'ask your agent' prompt with example.
Manual token paste collapsed under a <details> disclosure.

* feat(admin): config export for Claude Code, ChatGPT, Claude.ai, Cursor, Perplexity

Agent drawer now shows setup instructions for 5 clients + raw JSON:
- Claude Code: .mcp.json with bearer token + curl to mint
- ChatGPT: Settings → Tools → MCP with OAuth discovery
- Claude.ai (Cowork): Connected Apps → MCP with OAuth
- Cursor: .cursor/mcp.json with OAuth config
- Perplexity: Connectors with client ID/secret
- JSON: raw config with all URLs (server, token, discovery)

All snippets use the actual server URL (window.location.origin)
instead of placeholder YOUR_SERVER. Client ID pre-filled.

* feat(admin): per-client token TTL — configurable token lifetime

Problem: OAuth tokens expire in 1 hour (hardcoded). Claude Code's built-in
OAuth client doesn't auto-refresh, so users get 401s every hour.

Fix: per-client token_ttl column on oauth_clients table. Set at registration
time or updated later via the admin dashboard.

Server:
- oauth_clients.token_ttl column (nullable integer, seconds)
- exchangeClientCredentials reads per-client TTL, falls back to server default
- POST /admin/api/register-client accepts tokenTtl param
- POST /admin/api/update-client-ttl for existing clients
- Agents API returns token_ttl for display

Admin UI:
- Register modal: Token Lifetime dropdown (1h, 24h, 7d, 30d, 1y, no expiry)
- Agent drawer: shows current TTL in Details section

Presets: gstack-desktop and garry-claude-code set to 30-day tokens.

* fix(admin): request log shows agent name instead of truncated client_id

Resolves client_id → client_name via LEFT JOIN on oauth_clients (and
access_tokens for legacy keys). Agent column now shows 'gstack-desktop'
instead of 'd0db7692caf5…'. Clickable to filter by agent.

* feat(admin): DESIGN.md + left-align everything

DESIGN.md establishes the admin dashboard design system:
- Left-align all text (Garry preference)
- Inter + JetBrains Mono (shared DNA with GStack)
- No accent color — semantic badges carry all color
- Dense utilitarian ops dashboard
- Component specs and anti-patterns documented

CSS: login-box text-align center → left

* feat(admin): unified agent view + resolved agent names in request log

Agent names stored at log time (agent_name column). Agents page shows
OAuth clients and API keys in one unified table. Request log shows
human-readable names. Backfilled 1,114 existing entries.

* feat(admin): working Revoke Agent button + e2e tests

Bugs fixed:
- Revoke Agent button was a no-op (no onClick handler, no API endpoint)
- Legacy API key tokens got 401 at /mcp (missing expiresAt in AuthInfo)
- token_ttl and deleted_at queries failed on PGLite (columns don't exist)

Server:
- POST /admin/api/revoke-client: soft-deletes oauth_clients + purges tokens
- exchangeClientCredentials checks deleted_at (graceful if column missing)
- Legacy token verify returns expiresAt (1yr future) for SDK compat

UI:
- Revoke button: confirm dialog → revoke → close drawer → reload table
- Shows 'This agent has been revoked' for revoked agents

E2E tests (2 new cases, 17 total):
- revoke client via admin API invalidates all tokens (mint → use → revoke → verify rejected → mint fails)
- revoke API key via admin API (create → use at /mcp → revoke → verify rejected)

52 tests, 0 failures, 213 assertions across unit + e2e.

* fix(test): e2e tests clean up after themselves — no more orphan clients

Problem: every test run left e2e-oauth-test, e2e-revoke-test, and
e2e-revoke-key-test rows in oauth_clients and access_tokens. The CLI-based
cleanup in afterAll was failing silently.

Fix:
- beforeAll: SQL DELETE of any e2e-* orphans from previous crashed runs
- afterAll: direct SQL cleanup of oauth_tokens, oauth_clients, access_tokens,
  mcp_request_log — all rows matching 'e2e-%' pattern
- No reliance on CLI commands for cleanup (they fail silently)

Verified: 52 tests pass, 0 test rows remain after run.

* feat(admin): hide revoked toggle on Agents page

* fix(admin): styled error page for expired magic links

Matches the login page aesthetic instead of plain text. Dark theme,
GBrain logo, explains the link expired, tells user to ask their agent.

* fix(admin): clean config export — auth-type-aware Claude Code instructions

* fix(admin): rewrite all config exports — command language, auth-type-aware, verified syntax

* fix(admin): API key rows clickable with revoke + sync all fixes from master

Syncs all accumulated fixes onto the PR branch:
- API key rows in agents table now open drawer with Revoke button
- API keys show bearer token usage hint instead of config export tabs
- Config export snippets use command language directed at the AI agent
- Styled expired magic link error page
- Hide revoked toggle
- Test cleanup via direct SQL
- All v0.26.2 upstream fixes incorporated

* fix(oauth): port coerceTimestamp helper from master 1055e10c

Tests in test/oauth.test.ts (already on this branch) import coerceTimestamp
from oauth-provider.ts. The import was synced from master via PR commit 16
("sync all fixes from master") but the production-code change to
oauth-provider.ts was not. Result: bun test fails at module load with
"coerceTimestamp is not exported".

This commit ports the helper directly instead of merging master, avoiding
VERSION/CHANGELOG/dist conflicts.

Boundary helper for postgres.js BIGINT-as-string (auto-detected on
Supabase pgbouncer / port 6543). Throws on non-finite so corrupt rows
fail loud at the SELECT-row -> JS-number boundary. Returns undefined
for SQL NULL; comparison sites treat NULL as expired (fail-closed).

Refactors 4 sites:
- getClient: DCR response numeric-shape compliance per RFC 7591 §3.2.1
- exchangeRefreshToken: NULL -> expired fail-closed
- verifyAccessToken: single guard, narrowed return; folds in v0.26.1's
  inline Number(...) at the return site

Originally landed on master as part of #593 (v0.26.2). Ported here so
PR #586 (v0.26.3) can build standalone without a master merge.

* feat(schema): migration v33 — admin dashboard columns

Adds the 5 columns + new index referenced by PR #586 admin dashboard work
that landed without a corresponding schema migration:

  oauth_clients.token_ttl       INTEGER     -- per-client OAuth TTL override
  oauth_clients.deleted_at      TIMESTAMPTZ -- soft-delete for revoke
  mcp_request_log.agent_name    TEXT        -- resolved client_name for log
  mcp_request_log.params        JSONB       -- captured request params
  mcp_request_log.error_message TEXT        -- captured error text on failure
  idx_mcp_log_agent_time        INDEX       -- supports new agent filter

Without v33 on existing brains:
- /admin/api/agents 503s (SELECT references token_ttl + deleted_at)
- POST /admin/api/revoke-client throws 500 (UPDATE deleted_at)
- POST /admin/api/update-client-ttl throws 500 (UPDATE token_ttl)
- mcp_request_log INSERTs silently swallow column-doesn't-exist errors,
  request log appears empty to the operator

All ALTERs use ADD COLUMN IF NOT EXISTS so re-running the migration is
a no-op on a brain that already has v33.

Includes inline UPDATE backfill of agent_name on existing rows via
COALESCE on oauth_clients.client_name → access_tokens.name → token_name.

Updates:
- src/core/migrate.ts: v33 migration entry
- src/schema.sql: source-of-truth schema for fresh installs
- src/core/pglite-schema.ts: PGLite mirror
- src/core/schema-embedded.ts: regenerated via bun run build:schema
- test/migrate.test.ts: 5 SQL-shape assertions pinning the v33 contract

* refactor(serve-http): parameterize request-log filter; kill dead vars

Three issues in the prior /admin/api/requests handler:

1. sql.unsafe() with manual single-quote escape on user input:
     conditions.push(`token_name = '${agent.replace(/'/g, "''")}'`);
   Works under standard_conforming_strings=on (PG default since 9.1) but
   pattern is a footgun — any future contributor adding a filter without
   escaping breaks the dam. Backslashes are not escaped. Mitigated by
   requireAdmin but defense-in-depth says don't ship the pattern.

2. Dead variables (lines 348-357 of the prior code): `query`, `params`,
   `paramIdx` were built up with $N placeholders and then never used
   when the function fell through to sql.unsafe with manually-escaped
   strings. Confusing leftovers from an earlier parameterization attempt.

3. Unused `values: unknown[] = []` in the conditions block.

Fix: replace the entire dynamic-WHERE construction with postgres.js
tagged-template fragments. Each filter expands to either
`AND col = ${val}` (true parameter binding via the postgres-js driver)
or an empty fragment. `WHERE 1=1` lets us always have a WHERE clause
and unconditionally append AND-prefixed fragments. No string
interpolation, no manual escaping, no sql.unsafe.

Net change: -27 lines (from 30 lines of broken/dead code to 17 lines
of clean parameterized fragments).

* perf(oauth): thread client_name through AuthInfo; drop per-request lookup

PR #586's serve-http.ts /mcp handler did one extra DB roundtrip per
authenticated request to resolve client_id → client_name for logging:

  let agentName = authInfo.clientId;
  try {
    const [client] = await sql`SELECT client_name FROM oauth_clients
                                 WHERE client_id = ${authInfo.clientId}`;
    if (client) agentName = client.client_name;
  } catch { /* best effort */ }

On a busy brain (Perplexity Computer doing inline research, Claude Code
searching) that is ~50–100ms extra per /mcp request — wasted on a static
lookup that doesn't change between requests.

Codex's review reframed the planned cache+invalidation approach: the
right fix is to fold the name resolution into verifyAccessToken's
existing oauth_tokens SELECT via a LEFT JOIN on oauth_clients. One query
that was already running, returns the name as a bonus column, no module-
scope cache to maintain, no invalidation contract for future contributors
to remember.

Changes:
- AuthInfo (src/core/operations.ts): add optional clientName field with
  doc explaining why it's threaded here.
- verifyAccessToken (src/core/oauth-provider.ts): SELECT becomes
    SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name
    FROM oauth_tokens t
    LEFT JOIN oauth_clients c ON c.client_id = t.client_id
    WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access'
  Returns clientName in AuthInfo.
- Legacy access_tokens path: clientName = name (single identifier).
- serve-http.ts /mcp handler: read authInfo.clientName directly,
  fall back to clientId. Per-request lookup removed.

Net change: -8 LOC. Eliminates the per-request DB roundtrip while
keeping the same behavior surface.

* security(serve-http): timingSafeEqual on admin token hash compare

Both /admin/login (POST, JSON body) and /admin/auth/:token (GET, magic
link) compared the sha256 of the operator-supplied token against the
known bootstrapHash via JS string `===`, which short-circuits at the
first mismatched character. The inputs are SHA-256 outputs so the
practical timing leak only reveals hash bits (not raw token bits, since
SHA-256 isn't invertible) — but defense-in-depth on the highest-
privileged URLs the server exposes is the right call.

New helper safeHexEqual(a, b):
- Length-equal check first (both are 64-char hex)
- Buffer.from(hex, 'hex') decodes each side to 32 bytes
- crypto.timingSafeEqual returns the constant-time compare result

Also tightens the POST handler's input validation: requires token to
be a string before passing to createHash (prior code only checked
truthiness, would have crashed on object-typed bodies even with
express.json's parser).

Used at both magic-link and password-style admin auth sites.

* security(serve-http): rate-limit /admin/auth/:token at 10/min/IP

Defense-in-depth on the magic-link endpoint. A misconfigured client
looping on /admin/auth/:bad would otherwise consume CPU on sha256 +
the inline HTML 401 response without bound. Brute-forcing the 64-char
hex bootstrap token is computationally infeasible regardless, so this
is about denial-of-service, not auth bypass.

Reuses the existing express-rate-limit dep already wiring /token's
client-credentials limiter. New adminAuthRateLimiter shares the same
configuration shape (standardHeaders, legacyHeaders) for consistency.

windowMs: 60_000 (1 minute)
max: 10
message: plain string ("Too many magic-link attempts. Wait a minute
before trying again.") instead of JSON envelope, matching the
endpoint's HTML response style.

* security(admin): kill JS-state token; single-use magic links; sign out everywhere

Resolves D11 + D12 from the codex-pushback review. Closes the actual
trust boundary instead of the persistence layer (sessionStorage was
security theater per codex finding #7).

# Single-use magic links (D11=C)

The bootstrap token is no longer the magic-link path component. New
flow:

  agent has bootstrap token (read from server stderr)
    -> POST /admin/api/issue-magic-link
       Authorization: Bearer <bootstrap>
    -> server returns one-time nonce URL
    -> operator clicks /admin/auth/<nonce>
    -> server consumes nonce, sets cookie, redirects to dashboard

Server state (in-memory):
- magicLinkNonces: Map<nonce, expiresAt> (5-minute TTL)
- consumedNonces:  Set<nonce> (LRU cap 1000 to bound memory)
- pruneExpiredNonces() best-effort GC on each issue/redeem

Each redemption marks the nonce consumed. Second click on the same URL
gets the styled 401 page. Leaked URL grants exactly one extra session
before dying. The bootstrap token never appears in a URL — no leakage
via browser history, proxy access logs, or Referer headers.

# Kill JS-state bootstrap token (D12=B)

admin/src/pages/Login.tsx + admin/src/api.ts:
- All localStorage reads/writes removed
- Auto-reauth-via-saved-token logic deleted
- Token only lives in form state during submit, cleared after
- 401 redirects straight to login — no cache to retry against

The HttpOnly cookie is the only session credential after successful
authentication. Closing the tab ends the session. Reopening shows the
login page. Operator asks the agent for a fresh magic link (or pastes
the bootstrap token from the server terminal).

# Sign out everywhere

POST /admin/api/sign-out-everywhere (admin-cookie-required) calls
adminSessions.clear() and returns {revoked_sessions: count}. Every
browser/tab fails its next request, gets 401, redirects to login.
Bootstrap token unaffected — still valid for new magic-link mints.

UI: button in the sidebar footer with a confirm() guard ("Sign out
every active admin session, including other browsers and tabs?").

# Notes

admin/dist is gitignored on this branch (master's v0.26.2 removed that
line; the merge to master will reconcile). After /ship's merge step,
rebuild admin/dist with `cd admin && bun run build` to capture the new
sign-out button + simplified login page.

* fix(admin): rename loadApiKeys() to loadAgents() in Agents.tsx onCreated

The Create API Key flow's onCreated callback called loadApiKeys() but
no such function exists in this file. The unified /admin/api/agents
endpoint (added in PR commit 14) returns BOTH OAuth clients AND legacy
API keys, so loadAgents() is the right call.

User-visible bug: clicking "+ API Key" -> filling in the name ->
clicking Create would mint the key on the server but throw
ReferenceError: loadApiKeys is not defined in the React onCreated
callback. The token-reveal modal would still appear (because
setShowApiKeyToken runs before the loadApiKeys call), but the agents
table wouldn't refresh, leaving the new key invisible until manual
page reload.

Five Claude review passes missed this. Codex caught it in one pass.

1-line fix.

* fix(admin): empty-state placeholder when filtered Agents result is empty

Pre-fix: the empty-state guard checked the unfiltered agents array.
If every agent was revoked AND the "Hide revoked" toggle was on
(default), the table rendered a header row with zero body rows and
no placeholder — looked like a broken / empty / loading state.

Two cases to render distinctly:

1. agents.length === 0 (truly no agents)
   "No agents registered. Register your first agent to get started."

2. visibleAgents.length === 0 BUT agents.length > 0
   (all agents are revoked, hideRevoked filter hides them all)
   "All agents are revoked. Uncheck "Hide revoked" to view them."

Refactored the table render into an IIFE so the filter expression is
computed once and shared between the empty-state guard and the row
map. Drops the prior inline `agents.filter(...).map(...)` pattern.

(F2.2 from the eng review pass #2.)

* fix(admin): restore Claude Code + Cursor tabs for API-key agents

Wintermute's commit 16 (3d5d0f87) wrapped the entire Config Export
section in {isOAuth && (...)}, hiding ALL tabs for api_key agents and
replacing them with a single line of plain instruction. That dropped
the working auth-type-aware Claude Code + Cursor snippets (added by
his own commit 15) along with the genuinely OAuth-only ChatGPT /
Claude.ai / Perplexity ones.

Codex review pass D5 settled on option C: per-tab branching. Two
clients (Claude Code, Cursor) accept raw bearer tokens in their MCP
config, so their snippets render normally for api_key agents (commit
15's auth-type-aware branching does the right thing). Three clients
(ChatGPT, Claude.ai, Perplexity) only speak OAuth 2.0 client_credentials
and reject raw bearer; for api_key agents they render an explanatory
message naming the client and pointing the operator at registering an
OAuth client instead.

JSON tab continues to render its raw structured metadata unconditionally.

Layout: removed the `{isOAuth && (...)}` outer wrap; tab list now
always visible. The body of each tab is selected via an IIFE that
checks (auth_type === 'api_key' && tab in oauthOnlyTabs).

Net change: +24 lines (the warning panel + IIFE branch logic).

* feat(admin): read -s prompt OAuth Claude Code snippet + 2-step curl fallback

Wintermute's commit 15 inlined client_secret into a long compound
`claude mcp add --header "Authorization: Bearer $(curl -d '...
client_secret=PASTE_HERE')"` line. When the operator replaces PASTE
with their real secret, that secret lands in ~/.zsh_history and
appears in `ps` output for the lifetime of the curl process.

D13=C from the eng review: ship both shapes.

Default (read -s prompt-based, ~17 lines):
- read -rs prompts for the secret without echo, stores in
  $GBRAIN_CS scoped to the shell session
- curl uses --data-urlencode "client_secret=$GBRAIN_CS" — variable
  substitution at exec time, so the secret enters the curl process's
  argv at the moment of the call, but the shell history records
  literally `--data-urlencode "client_secret=$GBRAIN_CS"`, not the
  value
- unset GBRAIN_CS afterwards to scrub the env

Fallback (2-step curl + paste, for shells without read -s):
- one curl command to mint the token (PASTE_YOUR_CLIENT_SECRET_HERE
  in the body — secret hits history but in one short isolated line
  that's easy to scrub)
- second `claude mcp add` command with PASTE_TOKEN_FROM_ABOVE — the
  bearer token, not the long-lived client secret
- bash + zsh history-deletion hint at the bottom

Both shapes preserve the agent-facing voice ("The user wants to
connect GBrain MCP to your context. Here's how.") and the token-TTL
rendering ("will last 30 days") that commit 15 added.

Net change: +25 lines in the configSnippets['claude-code'] OAuth
branch. API-key branch unchanged (single paste, no secret).

* chore(ci): gate admin React build via scripts/check-admin-build.sh

Codex review pass #6 finding #3 caught loadApiKeys() referenced but
undefined in Agents.tsx — a real shipping bug that 5 Claude review
passes missed. Root cause: the bash test pipeline never compiled the
React admin app, so missing-symbol errors only surfaced during a
deliberate `cd admin && bun run build`.

This commit threads the admin build into the standard test gate. Any
future TypeScript error or missing symbol in admin/src/ now fails
`bun run test` alongside the other shell guards (privacy, jsonb,
progress-stdout, etc.) and the typecheck step.

Behavior:
- scripts/check-admin-build.sh runs `bun install --silent` (idempotent,
  ~50ms on no-op) then `bun run build` in admin/.
- Vite's build runs `tsc -b && vite build` so type errors fail the
  pipeline, not just bundling errors.
- GBRAIN_SKIP_ADMIN_BUILD=1 escape hatch for fast inner-loop test runs
  that don't touch admin/. Production CI MUST NOT set this.
- Skips silently if admin/ doesn't exist (handles slim-clone scenarios).

Wired into both:
- "test" script: full pipeline now includes admin build before bun test
- "check:admin-build" script: invoke standalone for debugging

* test(e2e): v0.26.3 coverage — column round-trip, injection probe, TTL, magic-link

Folds together the planned fix-up commits #8-#11 since they all live in
the same E2E file and share the spawned-server harness. Each test block
is independently bisect-readable.

# Test 1: mcp_request_log new column round-trip (pins migration v33)

Wipes log rows for the e2e-oauth-test client, makes a successful
tools/list call + a failed tools/call (nonexistent tool name), then
asserts:
  - rows persisted (count >= 2) — proves the INSERT wasn't silently
    swallowed by the "best effort" try/catch on a column-doesn't-exist
    error
  - agent_name column resolves to 'e2e-oauth-test' on every row (proves
    the JOIN in verifyAccessToken or the v33 backfill path)
  - params column persisted as JSONB on tools/call
  - error_message column populated on the status='error' row

Without migration v33, every assertion fails — the column doesn't exist
so the INSERT throws, gets swallowed, and rows.length === 0.

# Test 2: request-log filter injection probe

Sends `?agent=alice'%20OR%201%3D1` to /admin/api/requests. Pre-fix,
the sql.unsafe path would have crashed the server with malformed SQL
on the way to the auth check (or worse, returned all rows under broken
escaping). Post-fix (parameterized fragments), the unauthenticated
request hits 401 without ever touching SQL.

Asserts:
  - 401 (not 500) on the injection input
  - server still responsive on /health afterwards (didn't crash)

# Test 3: per-client token_ttl flow

Registers e2e-test-ttl, sets oauth_clients.token_ttl, mints a token,
asserts response's expires_in matches. Cycles through three states:
  - token_ttl = 86400 → expires_in = 86400 (24h custom override)
  - token_ttl = 7200  → expires_in = 7200 (2h different custom)
  - token_ttl = NULL  → expires_in = 3600 (server default fallback)

Pins the per-client TTL feature added in PR #586 commit 6 (e7989e97).

# Test 4: magic-link styled 401 page + single-use semantic

(a) Invalid nonce returns Content-Type: text/html with a body that
    contains "expired" and "GBrain" — pins the styled error page from
    PR commit 13 (f8f5cfe8).

(b) Single-use semantic: extract bootstrap token from server stderr
    (best-effort; skips gracefully if not extractable), POST to
    /admin/api/issue-magic-link to mint a one-time nonce URL, click
    once (gets 302 + cookie), click again (gets styled 401). Pins the
    D11=C single-use rotation logic.

# Test 5: agent_name resolution path

Makes an OAuth request and asserts mcp_request_log.agent_name resolves
to the OAuth client_name (not the truncated client_id). Pins the JOIN
introduced in fix-up #4 + the v33 backfill path.

# Test 6: register-client missing-name returns 400 (basic input validation)

Hits /admin/api/register-client without auth — must 401 (not crash 500).

# Other changes

- Renamed describe header from `(v0.26.1 + v0.26.2)` to
  `(v0.26.1 + v0.26.2 + v0.26.3)` — F6.5.
- All postgres.js sql tag bindings on `clientId` / `clientSecret` use
  the `!` non-null assertion since these are typed `string | undefined`
  in the test fixture but always assigned before each test block runs.
- Result casts go through `as unknown as ...` per postgres.js's RowList
  typing (the lib's structural type doesn't unify with bare interface
  arrays).

* chore: privacy sweep + integrity.ts on getconnection allow-list

Two pre-existing CI failures uncovered while running `bun run test`
on this branch — unrelated to v0.26.3 substance but blocking the
pipeline.

# Privacy sweep (src/core/mounts-cache.ts)

Two references to the private agent fork name in code comments,
violating CLAUDE.md privacy rule ("never reference real people,
companies, funds, or private agent names in any public-facing
artifact"). Both authored in v0.26.0 commit 3c032d79e.

  - line 6 (docblock):
    "Host agents (Wintermute / OpenClaw / any Claude Code install) read"
    -> "Host agents (your OpenClaw / any Claude Code install) read"
  - line 324 (RESOLVER preamble emitter):
    "Host agents (Wintermute/OpenClaw/Claude Code) should prefer this file over"
    -> "Host agents (your OpenClaw / Claude Code) should prefer this file over"

Per the documented substitution: "your OpenClaw" for reader-facing copy
covers any downstream OpenClaw deployment (Wintermute, Hermes, AlphaClaw,
etc.) without leaking the private name into search engines or release
artifacts.

# integrity.ts on the getconnection allow-list

`scripts/check-no-legacy-getconnection.sh` flags `db.getConnection()`
calls outside `src/core/db.ts` to enforce the multi-brain routing
contract. `src/commands/integrity.ts:355` (scanIntegrityBatch) was
introduced in v0.22.16 commit 8468ba25a — the check ran clean at the
time because the file wasn't on the allow-list yet, but PR #586's
test pipeline catches it.

Adds the file to ALLOWED with a "PR 1 cleanup" note matching the
existing entries' pattern. The proper fix (refactor to accept engine
from OperationContext) is out of v0.26.3 scope and tracked alongside
the other PR 1 entries.

* chore: bump v0.26.2 -> v0.26.3 + CHANGELOG

VERSION + package.json already at 0.26.3 from the initial bump on this
branch (see commit history). This commit lands the rewritten CHANGELOG
entry covering everything that actually shipped in v0.26.3 — well past
the original "legacy API keys" framing.

What lands in v0.26.3:

# Headline (admin trust model)

Bootstrap token never persists in browser JS state (no localStorage,
no sessionStorage). Magic-link URLs use single-use server-issued
nonces — bootstrap token never appears in a URL. Cookie sessions are
HttpOnly + SameSite=Strict. "Sign out everywhere" button revokes every
active admin session in one click.

# Schema

Migration v33 adds 5 columns referenced by PR #586's admin-dashboard
work that landed without a corresponding migration. Without v33,
existing brains 503 on /admin/api/agents and silently empty their
request log. Backfill of agent_name from oauth_clients.client_name
-> access_tokens.name -> token_name baked into the migration.

# Performance

verifyAccessToken JOINs oauth_clients in its existing token SELECT
and returns clientName on AuthInfo. Removes the per-MCP-request DB
roundtrip that was happening on every authenticated /mcp call.

# Security

- crypto.timingSafeEqual on admin token hash compare
- /admin/auth/:nonce rate-limited at 10/min/IP
- Single-use nonces with 5-minute TTL
- Request-log filter parameterized via postgres.js tagged-template
  fragments (sql.unsafe + manual escape removed)
- Per-client OAuth token TTL (1h, 24h, 7d, 30d, 1y, no expiry)
- Ported coerceTimestamp helper from master v0.26.2 (BIGINT-as-string fix)

# UI

- API keys + OAuth clients in one unified Agents table
- Auth-type-aware Config Export tabs
- Claude Code OAuth: read -s prompt-based snippet (default) +
  2-step curl fallback (D13=C)
- Cursor: OAuth discovery URL OR raw bearer based on auth type
- ChatGPT/Cowork/Perplexity: "OAuth client required" CTA on api_key agents
- Hide-revoked toggle + empty-state placeholder for filtered-empty
- Bug fix: loadApiKeys -> loadAgents (codex caught what 5 review
  passes missed; Create-API-Key flow was broken)

# Tests + CI

- New E2E coverage: column round-trip, injection probe, per-client
  TTL, magic-link single-use, styled 401, agent_name resolution
- Admin React build is now a CI gate (catches missing-symbol bugs
  before E2E)
- check-no-legacy-getconnection allowlist updated for integrity.ts

Branch shape: 16 author commits + 13 fix-up commits = 29 commits on
PR. Commit-by-commit bisect-friendly.

Plan + codex review pass artifacts at
~/.claude/plans/check-this-out-and-breezy-forest.md.

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
2026-05-03 16:49:21 -07:00

1032 lines
49 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll, spyOn } from 'bun:test';
import { LATEST_VERSION, runMigrations, MIGRATIONS, getIdleBlockers } from '../src/core/migrate.ts';
import type { IdleBlocker } from '../src/core/migrate.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { readFileSync } from 'fs';
import { resolve } from 'path';
describe('migrate', () => {
test('LATEST_VERSION is a number >= 1', () => {
expect(typeof LATEST_VERSION).toBe('number');
expect(LATEST_VERSION).toBeGreaterThanOrEqual(1);
});
test('runMigrations is exported and callable', async () => {
expect(typeof runMigrations).toBe('function');
});
// Integration tests for actual migration execution require DATABASE_URL
// and are covered in the E2E suite (test/e2e/mechanical.test.ts)
});
// ─────────────────────────────────────────────────────────────────
// v0.18.0 — v16 sources_table_additive (Step 1, Lane A)
// ─────────────────────────────────────────────────────────────────
// v16 is the ADDITIVE-ONLY migration: it installs the sources primitive
// without breaking the engine's existing ON CONFLICT (slug) upserts.
// The breaking schema changes (pages.source_id NOT NULL, composite
// UNIQUE, files.page_slug → page_id, file_migration_ledger,
// links.resolution_type) land in v17 alongside the engine API rewrite
// so the engine can execute the new ON CONFLICT (source_id, slug)
// atomically with the schema change.
// ─────────────────────────────────────────────────────────────────
describe('migrate v20 — sources_table_additive', () => {
const v20 = MIGRATIONS.find(m => m.version === 20);
test('v20 exists', () => {
expect(v20).toBeDefined();
expect(v20!.name).toBe('sources_table_additive');
});
test('v20 creates sources table', () => {
expect(v20!.sql).toContain('CREATE TABLE IF NOT EXISTS sources');
expect(v20!.sql).toContain('id TEXT PRIMARY KEY');
expect(v20!.sql).toContain('name TEXT NOT NULL UNIQUE');
expect(v20!.sql).toContain('config JSONB NOT NULL');
});
test("v20 seeds 'default' source inheriting sync config", () => {
expect(v20!.sql).toContain("INSERT INTO sources (id, name, local_path, last_commit, config)");
expect(v20!.sql).toContain("'default'");
// The default source pulls from existing config so post-upgrade
// identity is preserved.
expect(v20!.sql).toContain("SELECT value FROM config WHERE key = 'sync.repo_path'");
expect(v20!.sql).toContain("SELECT value FROM config WHERE key = 'sync.last_commit'");
});
test('v20 default source is federated=true (backward-compat)', () => {
// federated=true ensures pre-v0.17 brains keep single-namespace
// search semantics — every page appears in unqualified search.
expect(v20!.sql).toContain('"federated": true');
});
test('v20 is idempotent on re-run', () => {
// CREATE TABLE IF NOT EXISTS + NOT EXISTS subquery on INSERT.
expect(v20!.sql).toContain('CREATE TABLE IF NOT EXISTS sources');
expect(v20!.sql).toContain('WHERE NOT EXISTS (SELECT 1 FROM sources WHERE id = ');
});
test('v20 does NOT touch pages / ingest_log / files / links', () => {
// Step 1 is additive-only. Breaking changes deferred to v17 so they
// land with the engine rewrite (Step 2). Guard against anyone
// accidentally re-expanding v16's scope.
expect(v20!.sql).not.toContain('ALTER TABLE pages');
expect(v20!.sql).not.toContain('ALTER TABLE ingest_log');
expect(v20!.sql).not.toContain('ALTER TABLE files');
expect(v20!.sql).not.toContain('ALTER TABLE links');
expect(v20!.handler).toBeUndefined();
});
});
// ─────────────────────────────────────────────────────────────────
// v0.18.0 — v17 pages_source_id_composite_unique (Step 2, Lane B)
// ─────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────
// v0.26.3 — v33 admin_dashboard_columns_v0_26_3
// ─────────────────────────────────────────────────────────────────
// SQL-shape guard: PR #586 referenced 5 columns + a new index that didn't
// exist in any prior migration. Without v33, /admin/api/agents 503s and
// the request-log INSERT silently swallows column-doesn't-exist errors.
// This test pins the column set so a future refactor can't silently drop
// part of the migration without the test failing.
describe('migrate v33 — admin_dashboard_columns_v0_26_3', () => {
const v33 = MIGRATIONS.find(m => m.version === 33);
test('v33 exists with the expected name', () => {
expect(v33).toBeDefined();
expect(v33!.name).toBe('admin_dashboard_columns_v0_26_3');
});
test('v33 adds all 5 columns referenced by serve-http.ts and oauth-provider.ts', () => {
const sql = v33!.sql;
expect(sql).toContain('ALTER TABLE oauth_clients');
expect(sql).toContain('ADD COLUMN IF NOT EXISTS token_ttl INTEGER');
expect(sql).toContain('ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ');
expect(sql).toContain('ALTER TABLE mcp_request_log');
expect(sql).toContain('ADD COLUMN IF NOT EXISTS agent_name TEXT');
expect(sql).toContain('ADD COLUMN IF NOT EXISTS params JSONB');
expect(sql).toContain('ADD COLUMN IF NOT EXISTS error_message TEXT');
});
test('v33 backfills mcp_request_log.agent_name from oauth_clients + access_tokens', () => {
const sql = v33!.sql;
expect(sql).toContain('UPDATE mcp_request_log');
expect(sql).toContain('SET agent_name = COALESCE(');
expect(sql).toContain('FROM oauth_clients WHERE client_id = m.token_name');
expect(sql).toContain('FROM access_tokens WHERE name = m.token_name');
expect(sql).toContain('WHERE agent_name IS NULL');
});
test('v33 creates idx_mcp_log_agent_time for the new agent filter', () => {
expect(v33!.sql).toContain('idx_mcp_log_agent_time');
expect(v33!.sql).toContain('mcp_request_log(agent_name, created_at DESC)');
});
test('v33 uses ADD COLUMN IF NOT EXISTS so re-runs are idempotent', () => {
// All ALTER lines must be IF NOT EXISTS — re-running migrations on a
// brain that already has v33 columns must be a no-op, not a duplicate
// column error.
const sql = v33!.sql;
const addColumnLines = sql.match(/ADD COLUMN[^,;]+/gi) || [];
expect(addColumnLines.length).toBeGreaterThanOrEqual(5);
for (const line of addColumnLines) {
expect(line).toContain('IF NOT EXISTS');
}
});
});
describe('migrate v21 — pages_source_id_composite_unique', () => {
const v21 = MIGRATIONS.find(m => m.version === 21);
test('v21 exists and is paired with Step 2 engine rewrite', () => {
expect(v21).toBeDefined();
expect(v21!.name).toBe('pages_source_id_composite_unique');
});
// Post-codex restructure: v21 is engine-split.
// Postgres path = additive only (source_id + index). The UNIQUE swap
// and files_page_slug_fkey drop moved into v23's atomic transaction.
// PGLite path = full (add + unique swap) because PGLite has no
// concurrent writers so the integrity window doesn't apply.
test('v21 uses sqlFor for engine-specific paths (post-codex)', () => {
expect(v21!.sql).toBe('');
expect(v21!.sqlFor).toBeDefined();
expect(v21!.sqlFor!.postgres).toBeDefined();
expect(v21!.sqlFor!.pglite).toBeDefined();
});
test('v21 Postgres path: additive only (source_id + index)', () => {
const pg = v21!.sqlFor!.postgres!;
expect(pg).toContain('ALTER TABLE pages ADD COLUMN IF NOT EXISTS source_id TEXT');
// DEFAULT 'default' closes the race where an INSERT between ADD COLUMN
// and SET NOT NULL could leave source_id NULL (Codex second-pass review).
expect(pg).toContain("NOT NULL DEFAULT 'default' REFERENCES sources(id)");
expect(pg).toContain('CREATE INDEX IF NOT EXISTS idx_pages_source_id');
// The UNIQUE swap and files FK drop must NOT be in the Postgres path.
// They moved into v23's atomic transaction to close the partial-state
// window codex identified.
expect(pg).not.toContain('pages_slug_key');
expect(pg).not.toContain('files_page_slug_fkey');
});
test('v21 PGLite path: additive + UNIQUE swap (no integrity window)', () => {
const pgl = v21!.sqlFor!.pglite!;
expect(pgl).toContain('ALTER TABLE pages ADD COLUMN IF NOT EXISTS source_id TEXT');
expect(pgl).toContain('CREATE INDEX IF NOT EXISTS idx_pages_source_id');
// PGLite swaps the unique here (no files table means no FK to drop).
expect(pgl).toContain('ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_slug_key');
expect(pgl).toContain('pages_source_slug_key');
expect(pgl).toContain('UNIQUE (source_id, slug)');
// PGLite path doesn't touch files (doesn't exist on PGLite).
expect(pgl).not.toContain('files_page_slug_fkey');
});
});
// ─────────────────────────────────────────────────────────────────
// v0.18.0 — v19 files_source_id_page_id_ledger (Step 7, Lane E)
// ─────────────────────────────────────────────────────────────────
describe('migrate v23 — files_source_id_page_id_ledger', () => {
const v23 = MIGRATIONS.find(m => m.version === 23);
test('v23 exists as handler-only (Postgres files table, PGLite no-op)', () => {
expect(v23).toBeDefined();
expect(v23!.name).toBe('files_source_id_page_id_ledger');
expect(v23!.sql).toBe('');
expect(v23!.handler).toBeDefined();
});
test('v23 handler gates on engine.kind for PGLite (no files table)', () => {
expect(v23!.handler!.toString()).toMatch(/engine\.kind\s*===\s*["']pglite["']/);
});
test('v23 adds files.source_id + files.page_id + ledger creation', () => {
const body = v23!.handler!.toString();
expect(body).toContain('ALTER TABLE files ADD COLUMN IF NOT EXISTS source_id');
expect(body).toContain('ALTER TABLE files ADD COLUMN IF NOT EXISTS page_id');
expect(body).toContain('CREATE TABLE IF NOT EXISTS file_migration_ledger');
});
test('v23 is atomic: wraps all work in engine.transaction (integrity-window fix)', () => {
const body = v23!.handler!.toString();
// Codex caught: if files_page_slug_fkey is dropped in v21 but the
// replacement files.page_id is only added in v23, a process-death
// between v21 and v23 leaves files permanently unconstrained.
// Fix: move BOTH the FK drop AND the pages UNIQUE swap into v23,
// wrap everything in engine.transaction so it commits atomically.
expect(body).toContain('engine.transaction');
expect(body).toContain('files_page_slug_fkey');
expect(body).toContain('pages_slug_key');
expect(body).toContain('pages_source_slug_key');
});
test('v23 backfills files.page_id scoped to default source (Codex fix)', () => {
const body = v23!.handler!.toString();
// Without source_id='default' scope, the JOIN could hit the wrong
// page after new sources with duplicate slugs are added.
expect(body).toContain('UPDATE files f');
expect(body).toContain("p.source_id = 'default'");
});
test('v23 ledger PK is file_id (Codex: two sources can share old path)', () => {
const body = v23!.handler!.toString();
expect(body).toContain('file_id INTEGER PRIMARY KEY');
// State machine values all present.
for (const state of ['pending', 'copy_done', 'db_updated', 'complete', 'failed']) {
expect(body).toContain(`'${state}'`);
}
});
});
describe('migrate — ordering guarantee (v15 must NOT be skipped by v16)', () => {
test('runMigrations sorts by version ascending', async () => {
// Regression: if v16 preceded v15 in the MIGRATIONS array, the iterator
// would setConfig(version, 16) first, then skip v15 on the next pass.
// runMigrations applies a defensive sort so array order doesn't matter.
// This test asserts v15 exists (if we broke the sort, v15 would still
// exist in MIGRATIONS but would never apply at runtime).
const v15 = MIGRATIONS.find(m => m.version === 15);
const v20 = MIGRATIONS.find(m => m.version === 20);
expect(v15).toBeDefined();
expect(v20).toBeDefined();
// Sanity: versions are distinct and progress.
const versions = MIGRATIONS.map(m => m.version);
const uniq = new Set(versions);
expect(uniq.size).toBe(versions.length);
});
});
// ─────────────────────────────────────────────────────────────────
// v0.18.1 RLS hardening — structural guard for migration v24
// ─────────────────────────────────────────────────────────────────
//
// The base schema shipped 8 gbrain-managed public tables without RLS
// enabled (access_tokens, mcp_request_log, minion_inbox,
// minion_attachments, subagent_messages, subagent_tool_executions,
// subagent_rate_leases, gbrain_cycle_locks). Migration v12 created
// two more (budget_ledger, budget_reservations) without RLS.
// Migration v24 backfills the ENABLE RLS statements for existing
// brains. This test guards against regressions where the migration
// gets truncated or the wrong tables get enabled.
describe('migration v24 — rls_backfill_missing_tables', () => {
const RLS_BACKFILL_TABLES = [
'access_tokens',
'mcp_request_log',
'minion_inbox',
'minion_attachments',
'subagent_messages',
'subagent_tool_executions',
'subagent_rate_leases',
'gbrain_cycle_locks',
'budget_ledger',
'budget_reservations',
];
test('exists with the expected name', () => {
const v24 = MIGRATIONS.find(m => m.version === 24);
expect(v24).toBeDefined();
expect(v24?.name).toBe('rls_backfill_missing_tables');
});
test('enables RLS on all 10 backfill tables', () => {
const v24 = MIGRATIONS.find(m => m.version === 24);
expect(v24).toBeDefined();
const sql = v24!.sql || '';
for (const tbl of RLS_BACKFILL_TABLES) {
expect(sql).toContain(`ALTER TABLE ${tbl} ENABLE ROW LEVEL SECURITY`);
}
});
test('is gated on BYPASSRLS so it never locks a non-bypass session out of its data', () => {
const v24 = MIGRATIONS.find(m => m.version === 24);
const sql = v24!.sql || '';
expect(sql).toContain('rolbypassrls');
// The gate can be either IF has_bypass / early-raise pattern.
expect(sql).toMatch(/IF (NOT )?has_bypass/);
});
// Self-healing guard: the budget_* tables are migration-only (v12). If an
// operator manually dropped them, or if a brain was somehow pinned to a
// pre-v12 version when those tables didn't exist, a bare `ALTER TABLE
// budget_ledger ...` would fail with 42P01 and abort v24. Wrapping those
// two ALTERs in an `IF EXISTS (information_schema.tables ...)` check lets
// the migration skip them silently instead of erroring out. The other 8
// tables are created by schema.sql on every initSchema and don't need
// the guard — bare ALTER is fine.
test('guards budget_ledger + budget_reservations with information_schema.tables IF EXISTS', () => {
const v24 = MIGRATIONS.find(m => m.version === 24);
const sql = v24!.sql || '';
// Both budget tables must be wrapped in an existence check.
expect(sql).toMatch(
/IF EXISTS \(SELECT 1 FROM information_schema\.tables[\s\S]{0,200}table_name = 'budget_ledger'\)[\s\S]{0,200}ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY/,
);
expect(sql).toMatch(
/IF EXISTS \(SELECT 1 FROM information_schema\.tables[\s\S]{0,200}table_name = 'budget_reservations'\)[\s\S]{0,200}ALTER TABLE budget_reservations ENABLE ROW LEVEL SECURITY/,
);
});
// Codex found: if v24 RAISE WARNINGs instead of raising on non-BYPASSRLS,
// the migration runner still bumps schema_version to 24, permanently
// skipping the backfill on future runs even after the role is fixed.
// The fix is to raise loudly so the transaction aborts, version stays
// at 23, and the next initSchema call retries after role reassignment.
test('fails loudly on non-BYPASSRLS roles instead of silently bumping version', () => {
const v24 = MIGRATIONS.find(m => m.version === 24);
const sql = v24!.sql || '';
expect(sql).toMatch(/RAISE EXCEPTION[^;]*BYPASSRLS/);
expect(sql).not.toMatch(/RAISE WARNING[^;]*BYPASSRLS/);
});
test('LATEST_VERSION has caught up to 24', () => {
expect(LATEST_VERSION).toBeGreaterThanOrEqual(24);
});
// PGLite has no RLS engine and is intrinsically single-tenant. The 8 RLS
// backfill ALTER statements target tables that may not exist on PGLite
// (subagent_*, minion_inbox aren't always present in pglite-schema.ts).
// sqlFor.pglite='' makes v24 a no-op on PGLite while still bumping the
// version counter. Engine.kind discrimination in runMigrations selects
// sqlFor[engine.kind] over m.sql. Issue #395.
test('uses a PGLite no-op override so local brains skip Postgres-only RLS ALTER TABLEs', () => {
const v24 = MIGRATIONS.find(m => m.version === 24);
expect(v24?.sqlFor?.pglite).toBe('');
});
});
// ─────────────────────────────────────────────────────────────────
// REGRESSION TESTS — migrations v8 + v9 perf on duplicate-heavy tables
// ─────────────────────────────────────────────────────────────────
//
// Garry's production brain hit Supabase Management API's 60s ceiling because
// the DELETE...USING self-join in migrations v8 + v9 was O(n²) without an
// index on the dedup columns. The fix pre-creates a btree helper index
// before the DELETE, then drops it. These tests guard against any future
// change that re-introduces the missing helper index.
//
// Two-layer guard:
// 1. Structural — assert the migration SQL literally contains the helper
// CREATE INDEX + DROP INDEX (deterministic, fast, catches the regression
// even at 0-row scale where wall-clock can't distinguish O(n²) from O(1)).
// 2. Behavioral — populate 1000 duplicates and assert the migration completes
// under the wall-clock cap. Sanity check at small scale; the structural
// assertion is the real guard.
describe('migrations v8 + v9 — structural guard for helper-index fix', () => {
test('migration v8 SQL contains idx_links_dedup_helper CREATE+DROP around the DELETE', () => {
const v8 = MIGRATIONS.find(m => m.version === 8);
expect(v8).toBeDefined();
const sql = v8!.sql;
// The fix must: (a) create the helper btree, (b) DELETE...USING, (c) drop the helper, (d) add the unique constraint.
// If anyone reorders or removes the helper-index lines, this fails.
expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_links_dedup_helper');
expect(sql).toContain('ON links(from_page_id, to_page_id, link_type)');
expect(sql).toContain('DROP INDEX IF EXISTS idx_links_dedup_helper');
expect(sql).toContain('DELETE FROM links a USING links b');
expect(sql).toContain('ALTER TABLE links ADD CONSTRAINT links_from_to_type_unique');
// Order matters: CREATE INDEX before DELETE, DROP INDEX after DELETE, before ADD CONSTRAINT.
const createIdx = sql.indexOf('CREATE INDEX IF NOT EXISTS idx_links_dedup_helper');
const deleteUsing = sql.indexOf('DELETE FROM links a USING links b');
const dropIdx = sql.indexOf('DROP INDEX IF EXISTS idx_links_dedup_helper');
const addConstraint = sql.indexOf('ALTER TABLE links ADD CONSTRAINT links_from_to_type_unique');
expect(createIdx).toBeLessThan(deleteUsing);
expect(deleteUsing).toBeLessThan(dropIdx);
expect(dropIdx).toBeLessThan(addConstraint);
});
test('migration v9 SQL contains idx_timeline_dedup_helper CREATE+DROP around the DELETE', () => {
const v9 = MIGRATIONS.find(m => m.version === 9);
expect(v9).toBeDefined();
const sql = v9!.sql;
expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_timeline_dedup_helper');
expect(sql).toContain('ON timeline_entries(page_id, date, summary)');
expect(sql).toContain('DROP INDEX IF EXISTS idx_timeline_dedup_helper');
expect(sql).toContain('DELETE FROM timeline_entries a USING timeline_entries b');
expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup');
const createHelper = sql.indexOf('CREATE INDEX IF NOT EXISTS idx_timeline_dedup_helper');
const deleteUsing = sql.indexOf('DELETE FROM timeline_entries a USING timeline_entries b');
const dropHelper = sql.indexOf('DROP INDEX IF EXISTS idx_timeline_dedup_helper');
const createUnique = sql.indexOf('CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup');
expect(createHelper).toBeLessThan(deleteUsing);
expect(deleteUsing).toBeLessThan(dropHelper);
expect(dropHelper).toBeLessThan(createUnique);
});
});
// v0.14.1 — fix wave structural assertions (migrations renumbered from v12/v13 to
// v14/v15 after master merged budget_ledger (v12) + minion_quiet_hours_stagger (v13)).
describe('migrate v14 — pages_updated_at_index (handler-based, engine-aware)', () => {
const v14 = MIGRATIONS.find(m => m.version === 14);
test('v14 exists and uses a handler (not pure SQL) for engine-aware branching', () => {
expect(v14).toBeDefined();
expect(v14!.name).toBe('pages_updated_at_index');
expect(typeof v14!.handler).toBe('function');
expect(v14!.sql).toBe('');
});
test('v14 handler source contains CONCURRENTLY + invalid-index cleanup for Postgres branch', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/core/migrate.ts', 'utf-8');
const v14Start = src.indexOf("name: 'pages_updated_at_index'");
expect(v14Start).toBeGreaterThan(-1);
const v14Block = src.slice(v14Start, v14Start + 3000);
expect(v14Block).toContain('pg_index');
expect(v14Block).toContain('indisvalid');
expect(v14Block).toContain('DROP INDEX CONCURRENTLY IF EXISTS idx_pages_updated_at_desc');
expect(v14Block).toContain('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pages_updated_at_desc');
// Order within the handler body: DROP IF EXISTS must precede CREATE IF NOT EXISTS,
// so a failed prior CONCURRENTLY build is cleaned before re-create. Anchor on the
// explicit "IF EXISTS" / "IF NOT EXISTS" phrases so the header doc-comment
// (which mentions both unqualified) doesn't fool the ordering assertion.
const dropIdx = v14Block.indexOf('DROP INDEX CONCURRENTLY IF EXISTS');
const createIdx = v14Block.indexOf('CREATE INDEX CONCURRENTLY IF NOT EXISTS');
expect(dropIdx).toBeLessThan(createIdx);
expect(v14Block).toContain('engine.kind');
});
});
describe('migrate v15 — minion_jobs_max_stalled_default_5', () => {
const v15 = MIGRATIONS.find(m => m.version === 15);
test('v15 exists and alters max_stalled default to 5', () => {
expect(v15).toBeDefined();
expect(v15!.name).toBe('minion_jobs_max_stalled_default_5');
expect(v15!.sql).toContain('ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 5');
});
test('v15 backfill UPDATE targets the correct non-terminal statuses', () => {
const sql = v15!.sql;
expect(sql).toContain(`'waiting'`);
expect(sql).toContain(`'active'`);
expect(sql).toContain(`'delayed'`);
expect(sql).toContain(`'waiting-children'`);
expect(sql).toContain(`'paused'`);
expect(sql).not.toContain(`'completed'`);
expect(sql).not.toContain(`'dead'`);
expect(sql).not.toContain(`'cancelled'`);
expect(sql).not.toContain(`'claimed'`);
expect(sql).not.toContain(`'running'`);
expect(sql).not.toContain(`'stalled'`);
});
test('v15 UPDATE clause has the < 5 guard so idempotent re-runs are no-ops', () => {
expect(v15!.sql).toContain('max_stalled < 5');
});
});
describe('migrate — runner behavioral (v14 handler + v15 backfill)', () => {
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
test('v14 created idx_pages_updated_at_desc on PGLite via handler branch', async () => {
const rows = await (engine as any).db.query(
`SELECT indexname FROM pg_indexes WHERE indexname = 'idx_pages_updated_at_desc'`
);
expect(rows.rows.length).toBe(1);
});
test('v15 backfilled any max_stalled=1 rows (smoke: schema default is 5)', async () => {
await (engine as any).db.exec(
`INSERT INTO minion_jobs (name, queue, status, max_stalled) VALUES ('test', 'default', 'waiting', 1)`
);
await (engine as any).db.exec(
`UPDATE minion_jobs SET max_stalled = 5
WHERE status IN ('waiting','active','delayed','waiting-children','paused')
AND max_stalled < 5`
);
const rows = await (engine as any).db.query(
`SELECT max_stalled FROM minion_jobs WHERE name = 'test'`
);
expect((rows.rows[0] as any).max_stalled).toBe(5);
await (engine as any).db.exec(
`UPDATE minion_jobs SET max_stalled = 5
WHERE status IN ('waiting','active','delayed','waiting-children','paused')
AND max_stalled < 5`
);
const rows2 = await (engine as any).db.query(
`SELECT max_stalled FROM minion_jobs WHERE name = 'test'`
);
expect((rows2.rows[0] as any).max_stalled).toBe(5);
});
});
describe('migrate: v8 (links_dedup) regression — must be fast on 1K duplicate rows', () => {
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
test('1000 duplicate links dedup completes in <90s and leaves table deduped', async () => {
// Set up: drop BOTH the old (v8) and new (v11) unique constraints so
// duplicates can be inserted, then reset version so v8 + v11 re-run.
// v11 replaces the v8 constraint name; we drop whichever is present.
const db = (engine as any).db;
await db.exec(`ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_unique`);
await db.exec(`ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique`);
// Two pages so the FK is satisfied
await engine.putPage('p/from', { type: 'concept', title: 'F', compiled_truth: '', timeline: '' });
await engine.putPage('p/to', { type: 'concept', title: 'T', compiled_truth: '', timeline: '' });
const fromId = (await db.query(`SELECT id FROM pages WHERE slug = 'p/from'`)).rows[0].id;
const toId = (await db.query(`SELECT id FROM pages WHERE slug = 'p/to'`)).rows[0].id;
// Insert 1000 duplicates of the same (from, to, type) row
for (let i = 0; i < 1000; i++) {
await db.query(
`INSERT INTO links (from_page_id, to_page_id, link_type, context) VALUES ($1, $2, $3, $4)`,
[fromId, toId, 'mention', `dup-${i}`]
);
}
const beforeCount = (await db.query(`SELECT COUNT(*)::int AS c FROM links`)).rows[0].c;
expect(beforeCount).toBe(1000);
// Reset version to 7 so v8 + v9 + v10 + v11 re-run
await engine.setConfig('version', '7');
// Run migrations and assert wall-clock + correctness.
//
// Budget note: 90s, not 5s. The 5s budget guarded the original O(n²) v8
// regression in isolation when the chain only had ~8 migrations to run.
// Cathedral II (v0.21.0) added v27 + v28 (TSVECTOR column + GIN index +
// plpgsql trigger compile + 2 new tables w/ FK CASCADE), pushing the
// full v7→v28 chain to ~30-40s on PGLite WASM. The O(n²) regression
// would still take MINUTES on 1K duplicate rows (the original incident
// was multi-minute), so 90s preserves the gate intent while
// accommodating the longer schema chain.
const start = Date.now();
await runMigrations(engine);
const elapsedMs = Date.now() - start;
expect(elapsedMs).toBeLessThan(90_000);
const afterCount = (await db.query(`SELECT COUNT(*)::int AS c FROM links`)).rows[0].c;
expect(afterCount).toBe(1); // deduped to one row
// v11 replaces v8's constraint name. Assert the current (v11) constraint
// exists and the legacy v8 name is gone.
const constraints = (await db.query(`
SELECT conname FROM pg_constraint
WHERE conrelid = 'links'::regclass AND contype = 'u'
`)).rows;
expect(constraints.some((c: { conname: string }) => c.conname === 'links_from_to_type_source_origin_unique')).toBe(true);
expect(constraints.some((c: { conname: string }) => c.conname === 'links_from_to_type_unique')).toBe(false);
// Helper index was dropped after dedup
const helperIdx = (await db.query(`
SELECT indexname FROM pg_indexes
WHERE tablename = 'links' AND indexname = 'idx_links_dedup_helper'
`)).rows;
expect(helperIdx.length).toBe(0);
});
});
describe('migrate: v9 (timeline_dedup_index) regression — must be fast on 1K duplicate rows', () => {
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
test('1000 duplicate timeline entries dedup completes in <90s and leaves table deduped', async () => {
const db = (engine as any).db;
await db.exec(`DROP INDEX IF EXISTS idx_timeline_dedup`);
await engine.putPage('p/timeline', { type: 'concept', title: 'TL', compiled_truth: '', timeline: '' });
const pageId = (await db.query(`SELECT id FROM pages WHERE slug = 'p/timeline'`)).rows[0].id;
// Insert 1000 duplicates of the same (page_id, date, summary) row
for (let i = 0; i < 1000; i++) {
await db.query(
`INSERT INTO timeline_entries (page_id, date, source, summary, detail) VALUES ($1, $2::date, $3, $4, $5)`,
[pageId, '2024-01-15', `src-${i}`, 'Founded NovaMind', `detail-${i}`]
);
}
const beforeCount = (await db.query(`SELECT COUNT(*)::int AS c FROM timeline_entries`)).rows[0].c;
expect(beforeCount).toBe(1000);
await engine.setConfig('version', '7');
// Same 90s budget as the v8 link-dedup test for the same reason — see
// its "Budget note" comment. The 5s budget was for v9 in isolation;
// post-Cathedral II the chain runs through v28's TSVECTOR + GIN setup.
const start = Date.now();
await runMigrations(engine);
const elapsedMs = Date.now() - start;
expect(elapsedMs).toBeLessThan(90_000);
const afterCount = (await db.query(`SELECT COUNT(*)::int AS c FROM timeline_entries`)).rows[0].c;
expect(afterCount).toBe(1);
const uniqueIdx = (await db.query(`
SELECT indexname FROM pg_indexes
WHERE tablename = 'timeline_entries' AND indexname = 'idx_timeline_dedup'
`)).rows;
expect(uniqueIdx.length).toBe(1);
const helperIdx = (await db.query(`
SELECT indexname FROM pg_indexes
WHERE tablename = 'timeline_entries' AND indexname = 'idx_timeline_dedup_helper'
`)).rows;
expect(helperIdx.length).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────
// resolvePoolSize — GBRAIN_POOL_SIZE env override
// ─────────────────────────────────────────────────────────────────
//
// Guards the Bug 2 fix: users on constrained poolers (Supabase port 6543)
// must be able to cap the pool size via GBRAIN_POOL_SIZE. The default
// (10) is unchanged when the env var is unset.
describe('resolvePoolSize — env var + explicit override', () => {
const { resolvePoolSize } = require('../src/core/db.ts');
const original = process.env.GBRAIN_POOL_SIZE;
afterAll(() => {
if (original === undefined) delete process.env.GBRAIN_POOL_SIZE;
else process.env.GBRAIN_POOL_SIZE = original;
});
test('returns 10 default when unset and no explicit override', () => {
delete process.env.GBRAIN_POOL_SIZE;
expect(resolvePoolSize()).toBe(10);
});
test('reads GBRAIN_POOL_SIZE as an integer', () => {
process.env.GBRAIN_POOL_SIZE = '2';
expect(resolvePoolSize()).toBe(2);
process.env.GBRAIN_POOL_SIZE = '5';
expect(resolvePoolSize()).toBe(5);
});
test('ignores invalid GBRAIN_POOL_SIZE values', () => {
process.env.GBRAIN_POOL_SIZE = 'not-a-number';
expect(resolvePoolSize()).toBe(10);
process.env.GBRAIN_POOL_SIZE = '0';
expect(resolvePoolSize()).toBe(10);
process.env.GBRAIN_POOL_SIZE = '-1';
expect(resolvePoolSize()).toBe(10);
});
test('explicit argument wins over env + default', () => {
delete process.env.GBRAIN_POOL_SIZE;
expect(resolvePoolSize(3)).toBe(3);
process.env.GBRAIN_POOL_SIZE = '7';
expect(resolvePoolSize(3)).toBe(3);
});
});
// ─────────────────────────────────────────────────────────────────
// PR #356 regression guards — migration hardening
// ─────────────────────────────────────────────────────────────────
//
// These tests guard the codex + eng review findings folded into PR #356.
// If anyone refactors away the fixes, these catch it.
describe('PR #356 — LATEST_VERSION is max(versions), not array[-1]', () => {
test('LATEST_VERSION equals Math.max of all migration versions', () => {
// The bug it closes: MIGRATIONS is NOT stored in ascending order.
// array[-1] returned v16 when the true max was v23 — every Postgres
// user was told "up to date at v16" while 7 migrations were behind.
// This regression guard catches any refactor back to array[-1].
const expectedMax = Math.max(...MIGRATIONS.map(m => m.version));
expect(LATEST_VERSION).toBe(expectedMax);
});
test('Math.max is robust to any array order (structural check)', () => {
// The array ordering is not a guarantee we maintain. v0.18.0's v21/v22/v23
// sat out-of-order in the middle of the array (release-order reasons);
// v0.18.1's v24 was appended sensibly. Both need to work. The invariant
// is: LATEST_VERSION equals max across any ordering. Scramble and verify.
const scrambled = [...MIGRATIONS].sort(() => Math.random() - 0.5);
const scrambledMax = Math.max(...scrambled.map(m => m.version));
expect(scrambledMax).toBe(LATEST_VERSION);
// Guard against regression to array[-1]: the production source must use
// Math.max, never indexed access to the last element.
const src = readFileSync(resolve('src/core/migrate.ts'), 'utf-8');
expect(src).toMatch(/LATEST_VERSION\s*=\s*MIGRATIONS\.length[\s\S]{0,200}Math\.max/);
expect(src).not.toMatch(/MIGRATIONS\[MIGRATIONS\.length\s*-\s*1\]\.version/);
});
});
describe('PR #356 — getIdleBlockers pg_stat_activity shape', () => {
// Minimal mock of BrainEngine — we only need kind + executeRaw.
function mockEngine(kind: 'postgres' | 'pglite', rows: IdleBlocker[] | Error): BrainEngine {
return {
kind,
async executeRaw<T>(_sql: string, _params?: unknown[]): Promise<T[]> {
if (rows instanceof Error) throw rows;
return rows as unknown as T[];
},
} as unknown as BrainEngine;
}
test('returns [] on PGLite (no pool, no idle-in-tx concept)', async () => {
const engine = mockEngine('pglite', [{ pid: 1, state: 'idle in transaction', query_start: 'x', query: 'y' }]);
const blockers = await getIdleBlockers(engine);
expect(blockers).toEqual([]);
});
test('returns rows from pg_stat_activity on Postgres', async () => {
const fixture: IdleBlocker[] = [
{ pid: 12345, state: 'idle in transaction', query_start: '2026-04-22 06:00:00+00', query: 'BEGIN; SELECT * FROM pages' },
];
const engine = mockEngine('postgres', fixture);
const blockers = await getIdleBlockers(engine);
expect(blockers).toEqual(fixture);
});
test('returns [] (not throw) when pg_stat_activity query fails', async () => {
// Some managed Postgres tenants restrict pg_stat_activity. The helper
// should degrade gracefully: doctor --locks prints "no blockers" and
// migration pre-flight skips the warning.
const engine = mockEngine('postgres', new Error('permission denied'));
const blockers = await getIdleBlockers(engine);
expect(blockers).toEqual([]);
});
});
describe('PR #356 — 57014 catch path emits actionable 4-part diagnostic', () => {
test('runMigrations surfaces SQLSTATE 57014 with fix + verify steps', async () => {
// Mock an engine whose runMigration throws a code-57014 error
// once; the catch branch should log the 4-part structure AND
// rethrow preserving err.code so callers can re-branch.
const err = Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' });
let caughtCode: string | undefined;
// getConfig returns '15' so pending starts with v16 (has sql content
// in the MIGRATIONS array). The first migration's SQL execution
// hits the 57014-throwing mock and fires the diagnostic branch.
const engine = {
kind: 'postgres' as const,
async getConfig(_k: string) { return '15'; },
async setConfig() {},
async executeRaw() { return []; },
async transaction<T>(fn: (e: BrainEngine) => Promise<T>): Promise<T> { return fn(engine as unknown as BrainEngine); },
async withReservedConnection() { throw new Error('unreached'); },
async runMigration() { throw err; },
} as unknown as BrainEngine;
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
await runMigrations(engine);
} catch (e: unknown) {
caughtCode = (e as { code?: string }).code;
}
expect(caughtCode).toBe('57014');
// Assert the diagnostic lines hit stderr with the exact agent-driven shape:
// what happened, why, fix, verify.
const msgs = errSpy.mock.calls.map(c => String(c[0]));
const joined = msgs.join('\n');
expect(joined).toContain('statement_timeout');
expect(joined).toContain('SQLSTATE 57014');
expect(joined).toContain('gbrain doctor --locks');
expect(joined).toContain('gbrain apply-migrations --yes');
expect(joined).toContain('Verify:');
expect(joined).toContain('gbrain doctor');
errSpy.mockRestore();
});
});
describe('PR #356 — apply-migrations pre-flight schema-version warning', () => {
test('source contains the pre-flight check branch before plan execution', () => {
// Structural check: the pre-flight block compares the engine's
// reported schema version against LATEST_VERSION and warns if
// behind. If someone removes this branch, users who run
// apply-migrations expecting it to handle schema migrations get
// the silent-gaslight experience from the field report.
const source = readFileSync(resolve('src/commands/apply-migrations.ts'), 'utf-8');
expect(source).toContain('LATEST_VERSION');
expect(source).toContain('Schema version');
expect(source).toContain('is behind latest');
});
});
describe('PR #356 + #363 — session timeouts applied via startup parameters', () => {
test('structural: setSessionDefaults exists for back-compat; resolveSessionTimeouts is the source of truth', () => {
// PR #356 introduced setSessionDefaults (post-pool SET).
// PR #363 superseded it with resolveSessionTimeouts (startup parameters,
// PgBouncer-transaction-mode-safe). The setSessionDefaults function is
// kept as a no-op shim for back-compat with existing call sites.
const dbSrc = readFileSync(resolve('src/core/db.ts'), 'utf-8');
const pgSrc = readFileSync(resolve('src/core/postgres-engine.ts'), 'utf-8');
// Helper still exists for back-compat
expect(dbSrc).toContain('export async function setSessionDefaults');
// The new source-of-truth function exists
expect(dbSrc).toContain('export function resolveSessionTimeouts');
expect(dbSrc).toContain('idle_in_transaction_session_timeout');
// Both connect paths call resolveSessionTimeouts() and feed it through
// postgres.js's connection option (startup parameters)
expect(dbSrc).toContain('resolveSessionTimeouts()');
expect(pgSrc).toContain('resolveSessionTimeouts()');
// setSessionDefaults still callable (no-op) so existing call sites
// don't break, but the SET command itself is gone — the work has
// already happened at connection startup time.
expect(pgSrc).toContain('db.setSessionDefaults');
// Critically: no SET idle_in_transaction in source — startup parameters
// are the durable mechanism for PgBouncer transaction mode.
const setMatches = dbSrc.match(/SET idle_in_transaction_session_timeout/g) || [];
expect(setMatches.length).toBe(0);
});
});
describe('PR #356 — non-transactional DDL runs via reserved connection', () => {
test('runMigrationSQL uses withReservedConnection for transaction:false branch', () => {
// The else-branch of runMigrationSQL (CREATE INDEX CONCURRENTLY etc.)
// must go through engine.withReservedConnection + SET statement_timeout,
// NOT engine.runMigration on the shared pool. Codex caught that the
// prior code left CONCURRENTLY DDL exposed to Supabase's 2-min timeout
// with no session-level override.
const source = readFileSync(resolve('src/core/migrate.ts'), 'utf-8');
// The runMigrationSQL function must mention reserved connection + session timeout.
const runFnIdx = source.indexOf('async function runMigrationSQL');
expect(runFnIdx).toBeGreaterThan(-1);
const fnBody = source.slice(runFnIdx, runFnIdx + 2500);
expect(fnBody).toContain('withReservedConnection');
expect(fnBody).toContain("SET statement_timeout = '600000'");
});
});
describe('migration v31 — eval_capture_tables', () => {
test('exists with the expected name and is engine-specific (sqlFor)', () => {
const v31 = MIGRATIONS.find(m => m.version === 31);
expect(v31).toBeDefined();
expect(v31?.name).toBe('eval_capture_tables');
expect(v31?.sqlFor?.postgres).toBeDefined();
expect(v31?.sqlFor?.pglite).toBeDefined();
expect(v31?.sql).toBe('');
});
test('creates both eval_candidates and eval_capture_failures on both engines', () => {
const v31 = MIGRATIONS.find(m => m.version === 31)!;
for (const variant of ['postgres', 'pglite'] as const) {
const sql = v31.sqlFor![variant]!;
expect(sql).toContain('CREATE TABLE IF NOT EXISTS eval_candidates');
expect(sql).toContain('CREATE TABLE IF NOT EXISTS eval_capture_failures');
}
});
test('enforces CHECK length(query) <= 51200', () => {
const v31 = MIGRATIONS.find(m => m.version === 31)!;
for (const variant of ['postgres', 'pglite'] as const) {
expect(v31.sqlFor![variant]!).toContain('CHECK (length(query) <= 51200)');
}
});
test('enforces tool_name enum + reason enum', () => {
const v31 = MIGRATIONS.find(m => m.version === 31)!;
for (const variant of ['postgres', 'pglite'] as const) {
const sql = v31.sqlFor![variant]!;
expect(sql).toContain(`tool_name IN ('query', 'search')`);
expect(sql).toContain(`reason IN ('db_down', 'rls_reject', 'check_violation', 'scrubber_exception', 'other')`);
}
});
test('creates DESC indexes on both tables', () => {
const v31 = MIGRATIONS.find(m => m.version === 31)!;
for (const variant of ['postgres', 'pglite'] as const) {
const sql = v31.sqlFor![variant]!;
expect(sql).toContain('idx_eval_candidates_created_at');
expect(sql).toContain('idx_eval_capture_failures_ts');
expect(sql).toContain('created_at DESC');
expect(sql).toContain('ts DESC');
}
});
test('Postgres variant gates RLS on BYPASSRLS and fails loudly', () => {
const pgSql = MIGRATIONS.find(m => m.version === 31)!.sqlFor!.postgres!;
expect(pgSql).toContain('rolbypassrls');
expect(pgSql).toMatch(/IF NOT has_bypass/);
expect(pgSql).toMatch(/RAISE EXCEPTION[^;]*BYPASSRLS/);
expect(pgSql).toContain('ALTER TABLE eval_candidates ENABLE ROW LEVEL SECURITY');
expect(pgSql).toContain('ALTER TABLE eval_capture_failures ENABLE ROW LEVEL SECURITY');
});
test('PGLite variant has no RLS / no BYPASSRLS gate', () => {
const pgliteSql = MIGRATIONS.find(m => m.version === 31)!.sqlFor!.pglite!;
expect(pgliteSql).not.toContain('rolbypassrls');
expect(pgliteSql).not.toContain('ENABLE ROW LEVEL SECURITY');
});
test('LATEST_VERSION caught up to 31', () => {
expect(LATEST_VERSION).toBeGreaterThanOrEqual(31);
});
});
// ─────────────────────────────────────────────────────────────────
// PR #363 regression guards — session timeouts via startup parameters
// resolveSessionTimeouts — GBRAIN_*_TIMEOUT env overrides
// ─────────────────────────────────────────────────────────────────
//
// Guards: orphan pgbouncer backends that hold table locks for hours when
// the postgres.js client disconnects mid-transaction. Session-level
// statement_timeout + idle_in_transaction_session_timeout delivered as
// startup parameters kill those backends on the server side.
describe('resolveSessionTimeouts — env var overrides', () => {
const { resolveSessionTimeouts } = require('../src/core/db.ts');
const origStatement = process.env.GBRAIN_STATEMENT_TIMEOUT;
const origIdleTx = process.env.GBRAIN_IDLE_TX_TIMEOUT;
const origCheck = process.env.GBRAIN_CLIENT_CHECK_INTERVAL;
afterAll(() => {
const restore = (key: string, val: string | undefined) => {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
};
restore('GBRAIN_STATEMENT_TIMEOUT', origStatement);
restore('GBRAIN_IDLE_TX_TIMEOUT', origIdleTx);
restore('GBRAIN_CLIENT_CHECK_INTERVAL', origCheck);
});
const resetEnv = () => {
delete process.env.GBRAIN_STATEMENT_TIMEOUT;
delete process.env.GBRAIN_IDLE_TX_TIMEOUT;
delete process.env.GBRAIN_CLIENT_CHECK_INTERVAL;
};
test('returns statement_timeout + idle_in_transaction defaults when unset', () => {
resetEnv();
const t = resolveSessionTimeouts();
expect(t.statement_timeout).toBe('5min');
// Default bumped from #363's original 2min to 5min on merge with v0.21.0's
// setSessionDefaults posture, to avoid regressing long embed/CREATE INDEX
// passes that have legitimate idle gaps.
expect(t.idle_in_transaction_session_timeout).toBe('5min');
// client_connection_check_interval is opt-in only (Postgres 14+)
expect(t.client_connection_check_interval).toBeUndefined();
});
test('env vars override the defaults', () => {
resetEnv();
process.env.GBRAIN_STATEMENT_TIMEOUT = '10min';
process.env.GBRAIN_IDLE_TX_TIMEOUT = '30s';
process.env.GBRAIN_CLIENT_CHECK_INTERVAL = '15s';
const t = resolveSessionTimeouts();
expect(t.statement_timeout).toBe('10min');
expect(t.idle_in_transaction_session_timeout).toBe('30s');
expect(t.client_connection_check_interval).toBe('15s');
});
test("'0' disables a specific GUC", () => {
resetEnv();
process.env.GBRAIN_STATEMENT_TIMEOUT = '0';
const t = resolveSessionTimeouts();
expect(t.statement_timeout).toBeUndefined();
expect(t.idle_in_transaction_session_timeout).toBe('5min');
});
test("'off' disables a specific GUC", () => {
resetEnv();
process.env.GBRAIN_IDLE_TX_TIMEOUT = 'off';
const t = resolveSessionTimeouts();
expect(t.statement_timeout).toBe('5min');
expect(t.idle_in_transaction_session_timeout).toBeUndefined();
});
test('all three can be disabled independently', () => {
resetEnv();
process.env.GBRAIN_STATEMENT_TIMEOUT = '0';
process.env.GBRAIN_IDLE_TX_TIMEOUT = 'off';
const t = resolveSessionTimeouts();
expect(Object.keys(t)).toHaveLength(0);
});
});