mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* fix(oauth): validate confidential revoke secrets * fix(oauth): harden confidential token revocation * chore: bump version and changelog (v0.42.64.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> --------- Co-authored-by: Robin <rayme@boltdsolutions.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: Garry Tan <garrytan@gmail.com>
2319 lines
105 KiB
TypeScript
2319 lines
105 KiB
TypeScript
/**
|
||
* GBrain HTTP MCP server with OAuth 2.1.
|
||
*
|
||
* Combines:
|
||
* - MCP SDK's mcpAuthRouter (OAuth endpoints: /authorize, /token, /register, /revoke)
|
||
* - Custom client_credentials handler (SDK doesn't support CC grant)
|
||
* - MCP tool calls at /mcp with bearer auth + scope enforcement
|
||
* - Admin dashboard at /admin with cookie auth
|
||
* - SSE live activity feed at /admin/events
|
||
* - Health check at /health
|
||
*/
|
||
|
||
import express from 'express';
|
||
import type { Request, Response, NextFunction } from 'express';
|
||
import cookieParser from 'cookie-parser';
|
||
import cors from 'cors';
|
||
import rateLimit from 'express-rate-limit';
|
||
import { randomBytes, createHash } from 'crypto';
|
||
import { safeHexEqual } from '../core/timing-safe.ts';
|
||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
||
import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||
import type { BrainEngine } from '../core/engine.ts';
|
||
import { operations, OperationError } from '../core/operations.ts';
|
||
import type { OperationContext, AuthInfo } from '../core/operations.ts';
|
||
import { GBrainOAuthProvider, validateTokenEndpointAuthMethod } from '../core/oauth-provider.ts';
|
||
import type { SqlQuery } from '../core/oauth-provider.ts';
|
||
import { hasScope, ALLOWED_SCOPES_LIST, normalizeScopesInput } from '../core/scope.ts';
|
||
import { summarizeMcpParams, dispatchToolCall } from '../mcp/dispatch.ts';
|
||
import { paramDefToSchema } from '../mcp/tool-defs.ts';
|
||
import { getBrainHotMemoryMeta } from '../core/facts/meta-hook.ts';
|
||
import { loadConfig } from '../core/config.ts';
|
||
import { buildError, serializeError } from '../core/errors.ts';
|
||
import { VERSION } from '../version.ts';
|
||
import * as db from '../core/db.ts';
|
||
import { sqlQueryForEngine, executeRawJsonb } from '../core/sql-query.ts';
|
||
import { MinionQueue } from '../core/minions/queue.ts';
|
||
import { isRetryableError } from '../core/retry-matcher.ts';
|
||
import {
|
||
computeContentHash,
|
||
validateIngestionEvent,
|
||
type IngestionContentType,
|
||
type IngestionEvent,
|
||
} from '../core/ingestion/types.ts';
|
||
|
||
/**
|
||
* /health endpoint timeout. 3s rather than 5s: Fly.io's default
|
||
* health-check timeout is 5s, so returning 503 right at the orchestrator
|
||
* deadline races with the orchestrator recording the request as a timeout.
|
||
* 3s leaves 2s of headroom for TCP, response framing, and clock skew.
|
||
*/
|
||
export const HEALTH_TIMEOUT_MS = 3000;
|
||
|
||
/**
|
||
* v0.36.1.x #1024: bootstrap token resolution.
|
||
*
|
||
* Pure helper (no side effects, no process.exit) so the rule is unit-testable.
|
||
* Two outcomes:
|
||
* - `ok`: caller proceeds with `{token, fromEnv}`. When the env value is
|
||
* undefined, a fresh 32-byte hex token is generated.
|
||
* - `error`: caller refuses to start. We require 32+ chars matching
|
||
* `[A-Za-z0-9_-]+` for env-supplied tokens — fail-closed beats silently
|
||
* accepting a weak admin secret.
|
||
*
|
||
* `randomBytesHex` is parameterized so tests can inject a deterministic
|
||
* fallback without monkey-patching `crypto.randomBytes`.
|
||
*/
|
||
export type BootstrapTokenResolution =
|
||
| { kind: 'ok'; token: string; fromEnv: boolean }
|
||
| { kind: 'error'; message: string };
|
||
|
||
export function resolveBootstrapToken(
|
||
envValue: string | undefined,
|
||
randomBytesHex: () => string = () => randomBytes(32).toString('hex'),
|
||
): BootstrapTokenResolution {
|
||
if (envValue === undefined) {
|
||
return { kind: 'ok', token: randomBytesHex(), fromEnv: false };
|
||
}
|
||
const trimmed = envValue.trim();
|
||
if (!/^[A-Za-z0-9_-]{32,}$/.test(trimmed)) {
|
||
return {
|
||
kind: 'error',
|
||
message:
|
||
'GBRAIN_ADMIN_BOOTSTRAP_TOKEN must be at least 32 chars and match [A-Za-z0-9_-]+.\n' +
|
||
' Refusing to start with a weak admin bootstrap token. Generate one with:\n' +
|
||
' head -c 32 /dev/urandom | base64 | tr -d "+/=" | head -c 48',
|
||
};
|
||
}
|
||
return { kind: 'ok', token: trimmed, fromEnv: true };
|
||
}
|
||
|
||
/**
|
||
* #2624: decide whether the generated admin bootstrap token is hidden from
|
||
* the startup banner. Fail-safe default: a generated token is NOT printed
|
||
* unless stderr is an interactive TTY, so containerized (non-TTY) deploys
|
||
* never ship the secret to centralized log storage. Env-sourced tokens are
|
||
* always hidden (operator already holds them). Explicit --suppress hides
|
||
* everything; --print-admin-token forces the raw value even on a non-TTY.
|
||
*/
|
||
export function shouldSuppressBootstrapPrint(opts: {
|
||
suppress: boolean;
|
||
fromEnv: boolean;
|
||
forcePrint: boolean;
|
||
isTty: boolean;
|
||
}): boolean {
|
||
if (opts.suppress) return true;
|
||
if (opts.fromEnv) return true;
|
||
if (opts.forcePrint) return false;
|
||
return !opts.isTty;
|
||
}
|
||
|
||
export type ProbeHealthResult =
|
||
| { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } }
|
||
| { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } };
|
||
|
||
/**
|
||
* Pure async health probe. Races `engine.getStats()` against a timeout,
|
||
* returns a tagged result. No Express coupling — easy to unit-test with a
|
||
* mock engine. The /health route handler is a thin wrapper around this.
|
||
*/
|
||
export async function probeHealth(
|
||
engine: BrainEngine,
|
||
engineName: string,
|
||
version: string,
|
||
timeoutMs: number = HEALTH_TIMEOUT_MS,
|
||
): Promise<ProbeHealthResult> {
|
||
// Capture the handle so we can clearTimeout when getStats() wins. Without
|
||
// this, every fast /health request leaves a 3s pending timer in the event
|
||
// loop until it fires — under high probe rates this builds up a rolling
|
||
// backlog of timers and avoidable wakeups. Both adversarial reviewers
|
||
// (Claude + Codex) flagged this independently.
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
try {
|
||
const stats = await Promise.race([
|
||
engine.getStats(),
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error('health_timeout')), timeoutMs);
|
||
}),
|
||
]);
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
body: { status: 'ok', version, engine: engineName, ...stats },
|
||
};
|
||
} catch (e: unknown) {
|
||
const msg = e instanceof Error ? e.message : 'unknown';
|
||
return {
|
||
ok: false,
|
||
status: 503,
|
||
body: {
|
||
error: 'service_unavailable',
|
||
error_description: msg === 'health_timeout'
|
||
? 'Health check timed out (database pool may be saturated)'
|
||
: 'Database connection failed',
|
||
},
|
||
};
|
||
} finally {
|
||
// Clear the timer regardless of which branch won the race. No-op when
|
||
// the timer already fired (we're in the timeout-rejection catch block).
|
||
if (timer !== null) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Lightweight liveness probe. Races `SELECT 1` against the same timeout
|
||
* `probeHealth` uses, returns the same tagged-union result type, but the
|
||
* 200 body is intentionally bare: `{status, version, engine}` — no engine
|
||
* stats. Stats moved to `/admin/api/full-stats` (admin auth) in v0.28.10
|
||
* because `getStats()`'s six count(*) queries exceeded HEALTH_TIMEOUT_MS
|
||
* on production brains through PgBouncer, producing false 503s that
|
||
* triggered orchestrator restart cascades and advisory-lock pile-ups.
|
||
*/
|
||
export async function probeLiveness(
|
||
sql: SqlQuery,
|
||
engineName: string,
|
||
version: string,
|
||
timeoutMs: number = HEALTH_TIMEOUT_MS,
|
||
): Promise<ProbeHealthResult> {
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
try {
|
||
await Promise.race([
|
||
sql`SELECT 1`,
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error('health_timeout')), timeoutMs);
|
||
}),
|
||
]);
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
body: { status: 'ok', version, engine: engineName },
|
||
};
|
||
} catch (e: unknown) {
|
||
const msg = e instanceof Error ? e.message : 'unknown';
|
||
return {
|
||
ok: false,
|
||
status: 503,
|
||
body: {
|
||
error: 'service_unavailable',
|
||
error_description: msg === 'health_timeout'
|
||
? 'Health check timed out (database pool may be saturated)'
|
||
: 'Database connection failed',
|
||
},
|
||
};
|
||
} finally {
|
||
if (timer !== null) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve `GBRAIN_HTTP_TRUST_PROXY` into a value Express's `app.set('trust
|
||
* proxy', ...)` accepts. Pure function so the test surface is one place,
|
||
* not the whole Express stack.
|
||
*
|
||
* Mapping:
|
||
* - unset / empty → 'loopback' (pre-v0.41.3 default; trusts only
|
||
* 127.0.0.1, ::1, ::ffff:127.0.0.1, fc00::/7)
|
||
* - '0' / 'false' → false (trust nothing; req.ip is socket peer regardless
|
||
* of X-Forwarded-For)
|
||
* - '1' / 'true' → 1 (trust exactly one hop; safe for Fly.io / Render /
|
||
* single-layer reverse proxy; matches the legacy transport's '==1' check)
|
||
* - other numeric → parseInt (trust N hops)
|
||
* - any other string → pass through verbatim (Express accepts named modes
|
||
* like 'uniquelocal', 'linklocal', and CIDR/IP lists)
|
||
*
|
||
* SECURITY: only set GBRAIN_HTTP_TRUST_PROXY when BOTH (a) gbrain is
|
||
* reachable only via a trusted reverse proxy, AND (b) the proxy strips
|
||
* client-supplied X-Forwarded-For headers before re-emitting its own.
|
||
* Otherwise clients can spoof their IP and defeat the pre-auth IP rate
|
||
* limit. See SECURITY.md "Reverse-proxy trust" for the full contract.
|
||
*/
|
||
export function resolveTrustProxy(env: string | undefined): string | number | boolean {
|
||
if (env === undefined || env === '') return 'loopback';
|
||
if (env === '0' || env === 'false') return false;
|
||
if (env === '1' || env === 'true') return 1;
|
||
if (/^\d+$/.test(env)) return parseInt(env, 10);
|
||
return env;
|
||
}
|
||
|
||
/**
|
||
* Parse `GBRAIN_HTTP_CORS_ORIGIN` into a Set of allowed origins for OAuth
|
||
* endpoints. Mirrors `src/mcp/http-transport.ts:parseCorsAllowlist`. Single
|
||
* env var so operators don't need to maintain two allowlists.
|
||
*
|
||
* Returns null when unset, empty, or whitespace-only — caller MUST treat
|
||
* null as "deny all cross-origin" (the same posture the legacy transport
|
||
* already takes).
|
||
*/
|
||
export function parseCorsAllowlistOAuth(): Set<string> | null {
|
||
const v = process.env.GBRAIN_HTTP_CORS_ORIGIN;
|
||
if (!v) return null;
|
||
const origins = v.split(',').map(s => s.trim()).filter(Boolean);
|
||
return origins.length === 0 ? null : new Set(origins);
|
||
}
|
||
|
||
/**
|
||
* Build a `cors.CorsOptions['origin']` value from the allowlist. The cors
|
||
* package accepts:
|
||
* - `false` → reject everything (no Allow-Origin header sent)
|
||
* - `(origin, cb) => cb(null, boolean)` → dynamic per-request check
|
||
* We use the function form when an allowlist is set so the value of the
|
||
* Allow-Origin header echoes the request Origin (RFC 6454) instead of a
|
||
* hardcoded string, and so the same options object covers all listed
|
||
* origins without enumeration in the response.
|
||
*
|
||
* Same-origin requests (no Origin header) get `cb(null, true)` which the
|
||
* cors package translates to "no CORS headers needed" — they're not
|
||
* cross-origin so they don't trigger the gate.
|
||
*/
|
||
export function resolveCorsOrigin(allowlist: Set<string> | null): cors.CorsOptions['origin'] {
|
||
if (allowlist === null) return false;
|
||
return (origin: string | undefined, cb: (err: Error | null, allow?: boolean) => void) => {
|
||
if (!origin) return cb(null, true);
|
||
cb(null, allowlist.has(origin));
|
||
};
|
||
}
|
||
|
||
interface ServeHttpOptions {
|
||
port: number;
|
||
tokenTtl: number;
|
||
enableDcr: boolean;
|
||
/**
|
||
* #1353: allow the consent-bypassing client_credentials grant on the DCR path.
|
||
* Off by default; DCR clients default to authorization_code. Implies enableDcr.
|
||
*/
|
||
enableDcrInsecure?: boolean;
|
||
/**
|
||
* Public URL the server is reachable at (e.g., https://brain.example.com).
|
||
* Used as the OAuth issuer in discovery metadata. Defaults to
|
||
* http://localhost:{port} when unset. Required for production deployments
|
||
* behind reverse proxies, ngrok tunnels, or any non-loopback URL — the
|
||
* issuer claim in tokens MUST match the discovery URL clients hit.
|
||
*/
|
||
publicUrl?: string;
|
||
/**
|
||
* When true, write raw request payloads to mcp_request_log + the admin SSE
|
||
* feed. Default false: payloads are summarized via dispatch.summarizeMcpParams
|
||
* (declared keys only, no values, no attacker-controlled key names).
|
||
*
|
||
* Operators running gbrain on their own laptop and debugging agent behavior
|
||
* can flip this on with `--log-full-params`. The flag prints a loud warning
|
||
* at startup so the privacy posture change is visible.
|
||
*/
|
||
logFullParams?: boolean;
|
||
/**
|
||
* Network interface(s) to bind. Defaults to `127.0.0.1` (loopback only) in
|
||
* v0.34.1+ — gbrain's primary use case is a personal-knowledge brain on a
|
||
* laptop, and the pre-v0.34 default of `0.0.0.0` made it one accidental
|
||
* `--http` invocation away from publishing the brain to a LAN.
|
||
*
|
||
* Server operators who DO want to accept remote connections pass
|
||
* `--bind 0.0.0.0` (or a specific interface IP). When `--public-url` is
|
||
* set but `--bind` is unset, a stderr WARN fires at startup recommending
|
||
* the explicit flag — defaulting to loopback while declaring a public URL
|
||
* is almost always a misconfiguration.
|
||
*/
|
||
bind?: string;
|
||
/**
|
||
* v0.36.x #1024: suppress the printed admin bootstrap token line on
|
||
* startup. Combined with `GBRAIN_ADMIN_BOOTSTRAP_TOKEN`, lets long-lived
|
||
* production deployments avoid leaking the token into log aggregators on
|
||
* every supervisor-managed restart. When the env var is NOT set, this
|
||
* flag still suppresses the print — operators take responsibility for
|
||
* tracking the regenerated value through other means.
|
||
*/
|
||
suppressBootstrapToken?: boolean;
|
||
/**
|
||
* #2624: force-print the generated admin bootstrap token even on a
|
||
* non-TTY (containerized) start. By default the raw token is only printed
|
||
* when stderr is an interactive TTY, so it never lands in centralized log
|
||
* storage for headless deploys. Set this when you genuinely need the value
|
||
* captured to a non-interactive log and accept the leak.
|
||
*/
|
||
printAdminToken?: boolean;
|
||
}
|
||
|
||
/**
|
||
* v0.38 Slice 4 — per-OAuth-client agent spend snapshot. Exported so the
|
||
* admin endpoint and `test/admin-agents-spend.test.ts` share the same SQL
|
||
* (single source of truth for the spend query shape).
|
||
*
|
||
* Returns one row per OAuth client that EITHER has the `agent` scope OR
|
||
* has at least one `bound_*` column set (the legacy admin client could
|
||
* also have bindings without scope='agent' on a partially-migrated brain;
|
||
* we want it visible in the viewer).
|
||
*
|
||
* Fields:
|
||
* - client_id, client_name
|
||
* - cap_usd_per_day: number | null (daily budget cap; NULL = no cap)
|
||
* - spent_cents_today: number (sum from mcp_spend_log, UTC-day-aligned)
|
||
* - pending_cents: number (sum of in-flight reservations, non-expired)
|
||
* - inflight_count: number (active subagent jobs owned by this client)
|
||
*
|
||
* Falls back to `[]` on any SQL error (pre-v0.38 brains where the v82-v84
|
||
* tables/columns don't yet exist).
|
||
*/
|
||
export interface AgentClientSpend {
|
||
client_id: string;
|
||
client_name: string;
|
||
cap_usd_per_day: number | null;
|
||
spent_cents_today: number;
|
||
pending_cents: number;
|
||
inflight_count: number;
|
||
}
|
||
|
||
export async function queryAgentClientSpend(engine: BrainEngine): Promise<AgentClientSpend[]> {
|
||
const sql = sqlQueryForEngine(engine);
|
||
const rows = await sql`
|
||
SELECT
|
||
c.client_id,
|
||
c.client_name,
|
||
COALESCE(c.budget_usd_per_day, NULL) AS cap_usd_per_day,
|
||
COALESCE((
|
||
SELECT SUM(spend_cents)::text
|
||
FROM mcp_spend_log
|
||
WHERE client_id = c.client_id
|
||
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC')
|
||
), '0') AS spent_cents_today,
|
||
COALESCE((
|
||
SELECT SUM(estimated_cents)::text
|
||
FROM mcp_spend_reservations
|
||
WHERE client_id = c.client_id
|
||
AND status = 'pending'
|
||
AND expires_at > now()
|
||
), '0') AS pending_cents,
|
||
COALESCE((
|
||
SELECT COUNT(*)::int
|
||
FROM minion_jobs
|
||
WHERE name = 'subagent'
|
||
AND status IN ('waiting', 'active', 'waiting-children')
|
||
AND data->>'__owner_client_id' = c.client_id
|
||
), 0) AS inflight_count
|
||
FROM oauth_clients c
|
||
WHERE c.deleted_at IS NULL
|
||
AND ('agent' = ANY (string_to_array(c.scope, ' ')) OR c.bound_tools IS NOT NULL)
|
||
ORDER BY c.client_name ASC
|
||
`;
|
||
return rows.map(r => ({
|
||
client_id: String(r.client_id),
|
||
client_name: String(r.client_name ?? r.client_id),
|
||
cap_usd_per_day: r.cap_usd_per_day !== null && r.cap_usd_per_day !== undefined
|
||
? parseFloat(String(r.cap_usd_per_day))
|
||
: null,
|
||
spent_cents_today: parseFloat(String(r.spent_cents_today ?? '0')),
|
||
pending_cents: parseFloat(String(r.pending_cents ?? '0')),
|
||
inflight_count: Number(r.inflight_count ?? 0),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Skill-publishing status for the startup banner + operator nudge. When OFF,
|
||
* connected agents (Codex / Claude Code / Perplexity / Cowork) cannot call
|
||
* `list_skills` / `get_skill`, so the host's skill catalog is INVISIBLE to them
|
||
* — the core tools (search / query / get_page / put_page / capture / think /
|
||
* find_experts) still work. Pure so the banner value + nudge copy are
|
||
* unit-tested without standing up a server. See `readMcpPublishSkills`
|
||
* (skill-catalog.ts) for the config resolution this status reflects.
|
||
*/
|
||
export function skillPublishStatus(publishSkills: boolean): { bannerValue: string; nudge: string | null } {
|
||
if (publishSkills) return { bannerValue: 'published', nudge: null };
|
||
return {
|
||
bannerValue: 'not published',
|
||
nudge:
|
||
"[serve-http] NOTE: skill publishing is OFF — connected agents can't call " +
|
||
'list_skills / get_skill, so this brain’s skill catalog is invisible to them ' +
|
||
'(core tools like search / query / think still work). Enable it with: ' +
|
||
'gbrain config set mcp.publish_skills true',
|
||
};
|
||
}
|
||
|
||
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
|
||
const { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams } = options;
|
||
// v0.34.1 (#864, D11): default bind flipped from 0.0.0.0 to 127.0.0.1.
|
||
// gbrain's primary use case is a personal-knowledge brain on a laptop;
|
||
// the pre-v0.34 default exposed brains on every interface. Server
|
||
// operators who need remote access pass `--bind 0.0.0.0` (or a specific
|
||
// interface). Declaring `--public-url` without `--bind` is almost always
|
||
// a misconfiguration; we WARN to stderr at startup in that case rather
|
||
// than silently binding loopback only.
|
||
const bind = options.bind ?? '127.0.0.1';
|
||
const config = loadConfig() || { engine: 'pglite' as const };
|
||
|
||
if (logFullParams) {
|
||
console.error(
|
||
'[serve-http] WARNING: --log-full-params writes raw request payloads to mcp_request_log + SSE feed. Disable for shared dashboards or production.',
|
||
);
|
||
}
|
||
|
||
if (publicUrl && options.bind === undefined) {
|
||
console.error(
|
||
'[serve-http] WARNING: --public-url is set but --bind is not. Default bind changed to 127.0.0.1 in v0.34.1; remote clients reaching the public URL will be refused. Pass --bind 0.0.0.0 to accept all interfaces.',
|
||
);
|
||
}
|
||
|
||
// Skill-publishing status for the banner + nudge. Mirrors readMcpPublishSkills
|
||
// (skill-catalog.ts): the DB plane (`gbrain config set`) wins over the file
|
||
// plane. When OFF, a connected coding agent can't see the host's skill
|
||
// catalog — surface that to the operator at startup rather than letting them
|
||
// discover it via an empty list_skills on the agent side.
|
||
let publishSkills = false;
|
||
try {
|
||
const dbVal = await engine.getConfig('mcp.publish_skills');
|
||
publishSkills = dbVal != null ? dbVal === 'true' : config?.mcp?.publish_skills === true;
|
||
} catch {
|
||
publishSkills = config?.mcp?.publish_skills === true;
|
||
}
|
||
const skillStatus = skillPublishStatus(publishSkills);
|
||
if (skillStatus.nudge) console.error(skillStatus.nudge);
|
||
|
||
// Note when this brain ships a brain-resident pack so the operator knows
|
||
// connecting harnesses will be offered it (only meaningful when publishing
|
||
// is on — list_brain_skillpack is gated by the same flag). Fail-open.
|
||
if (publishSkills) {
|
||
try {
|
||
const { loadAllSources } = await import('../core/sources-load.ts');
|
||
const { loadSkillpackManifest } = await import('../core/skillpack/manifest-v1.ts');
|
||
const { existsSync } = await import('fs');
|
||
const { join } = await import('path');
|
||
const srcs = await loadAllSources(engine);
|
||
let n = 0;
|
||
for (const s of srcs) {
|
||
if (!s.local_path || !existsSync(join(s.local_path, 'skillpack.json'))) continue;
|
||
try {
|
||
if (loadSkillpackManifest(s.local_path).brain_resident === true) n++;
|
||
} catch {
|
||
/* malformed pack → ignore */
|
||
}
|
||
}
|
||
if (n > 0) {
|
||
console.error(
|
||
`[serve-http] NOTE: ${n} source${n === 1 ? '' : 's'} ship a brain-resident skillpack — ` +
|
||
'connecting harnesses can discover it via list_brain_skillpack and will be offered to install it.',
|
||
);
|
||
}
|
||
} catch {
|
||
/* fail-open: banner is cosmetic */
|
||
}
|
||
}
|
||
|
||
// Engine-aware SQL adapter. Routes through engine.executeRaw on both
|
||
// Postgres and PGLite — the OAuth/admin/auth surface no longer requires
|
||
// a postgres.js singleton, so `gbrain serve --http` works against PGLite
|
||
// brains too. The narrow SqlQuery contract is scalar-binds-only; JSONB
|
||
// writes use executeRawJsonb (see mcp_request_log INSERT sites below).
|
||
const sql = sqlQueryForEngine(engine);
|
||
|
||
// Initialize OAuth provider. F12 cleanup: DCR-disable now flips a
|
||
// constructor option instead of monkey-patching `_clientsStore` after
|
||
// construction. Same outcome (no /register endpoint when --enable-dcr
|
||
// is not passed); cleaner shape for tests and future maintainers.
|
||
const oauthProvider = new GBrainOAuthProvider({
|
||
sql,
|
||
tokenTtl,
|
||
dcrDisabled: !enableDcr,
|
||
allowClientCredentialsDcr: enableDcrInsecure === true,
|
||
});
|
||
|
||
// #1353: loud stderr security WARN when DCR is enabled. DCR is an
|
||
// unauthenticated network registration endpoint; surface the posture change
|
||
// (and the extra blast radius of --enable-dcr-insecure) so it's visible in
|
||
// logs, not buried in the neutral "DCR: enabled" banner line.
|
||
if (enableDcr) {
|
||
console.error(
|
||
'SECURITY WARNING: Dynamic Client Registration (--enable-dcr) is ON. ' +
|
||
'Any network caller can self-register an OAuth client. DCR clients default ' +
|
||
'to the authorization_code (consent-bearing) grant. See SECURITY.md.',
|
||
);
|
||
if (enableDcrInsecure) {
|
||
console.error(
|
||
'SECURITY WARNING: --enable-dcr-insecure is ON — self-registered DCR ' +
|
||
'clients may request the client_credentials grant, which BYPASSES the ' +
|
||
'/authorize consent screen. Only use this on a trusted network.',
|
||
);
|
||
}
|
||
}
|
||
|
||
// Sweep expired tokens on startup (non-blocking)
|
||
try {
|
||
const swept = await oauthProvider.sweepExpiredTokens();
|
||
if (swept > 0) console.error(`Swept ${swept} expired tokens`);
|
||
} catch (e) {
|
||
console.error('Token sweep failed (non-blocking):', e instanceof Error ? e.message : e);
|
||
}
|
||
|
||
// v0.36.x #1024: bootstrap token sourcing.
|
||
//
|
||
// Default: regenerate per process start, print to stderr so the operator
|
||
// can paste into /admin login. Stable across restarts only when env var
|
||
// is set. The env override must be a strong secret — `[A-Za-z0-9_-]{32+}`
|
||
// — otherwise refuse to start. Logging the bootstrap-token value every
|
||
// restart is the original gripe; with `GBRAIN_ADMIN_BOOTSTRAP_TOKEN` set
|
||
// and `--suppress-bootstrap-token`, no value reaches the log.
|
||
const resolved = resolveBootstrapToken(process.env.GBRAIN_ADMIN_BOOTSTRAP_TOKEN);
|
||
if (resolved.kind === 'error') {
|
||
console.error(resolved.message);
|
||
process.exit(1);
|
||
}
|
||
let bootstrapToken: string = resolved.token;
|
||
let bootstrapFromEnv: boolean = resolved.fromEnv;
|
||
const bootstrapHash = createHash('sha256').update(bootstrapToken).digest('hex');
|
||
const suppressBootstrapPrint = shouldSuppressBootstrapPrint({
|
||
suppress: options.suppressBootstrapToken === true,
|
||
fromEnv: bootstrapFromEnv,
|
||
forcePrint: options.printAdminToken === true,
|
||
isTty: process.stderr.isTTY === true,
|
||
});
|
||
const adminSessions = new Map<string, number>(); // sessionId → expiresAt
|
||
|
||
// SSE clients for live activity feed
|
||
const sseClients = new Set<express.Response>();
|
||
|
||
// Broadcast MCP request event to all SSE clients
|
||
function broadcastEvent(event: Record<string, unknown>) {
|
||
const data = `data: ${JSON.stringify(event)}\n\n`;
|
||
for (const client of sseClients) {
|
||
try { client.write(data); } catch { sseClients.delete(client); }
|
||
}
|
||
}
|
||
|
||
// Express 5 app
|
||
const app = express();
|
||
// v0.41.3 (T8): configurable trust-proxy via GBRAIN_HTTP_TRUST_PROXY env.
|
||
// Default 'loopback' (trust Caddy/Tailscale on the same host) preserves
|
||
// pre-v0.41.3 behavior. Operators behind Fly.io / Render / Vercel / nginx
|
||
// set GBRAIN_HTTP_TRUST_PROXY=1 (one hop) so X-Forwarded-For lands as the
|
||
// real client IP for rate-limiting and req.secure detection. The legacy
|
||
// transport already reads this env var (src/mcp/http-transport.ts:111)
|
||
// for the same purpose; T8 makes the Express path agree.
|
||
app.set('trust proxy', resolveTrustProxy(process.env.GBRAIN_HTTP_TRUST_PROXY));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cookie parsing — required for /admin auth (express 5 has no built-in)
|
||
// ---------------------------------------------------------------------------
|
||
app.use(cookieParser());
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CORS (v0.41.3, T7 — default-deny on every OAuth endpoint)
|
||
// ---------------------------------------------------------------------------
|
||
// Pre-v0.41.3 every OAuth endpoint used bare `cors()` which defaults to
|
||
// `Access-Control-Allow-Origin: *` — any web origin could complete a token
|
||
// exchange from a logged-in operator's browser. The fix parses
|
||
// GBRAIN_HTTP_CORS_ORIGIN the same way the legacy transport already does
|
||
// (src/mcp/http-transport.ts:parseCorsAllowlist) and gates every OAuth
|
||
// surface behind the allowlist. When the env var is unset the OAuth
|
||
// endpoints reject all cross-origin requests (default deny). Same-origin
|
||
// requests are unaffected because browsers send no Origin header for them.
|
||
//
|
||
// The /admin SPA is the one cross-origin caller we expect on a personal
|
||
// laptop install; it ships co-located with the brain and uses
|
||
// same-origin XHR, so the lockdown doesn't break it.
|
||
const corsAllowlistOAuth = parseCorsAllowlistOAuth();
|
||
if (!corsAllowlistOAuth && bind === '0.0.0.0') {
|
||
console.error(
|
||
'[serve-http] WARNING: --bind 0.0.0.0 is set but GBRAIN_HTTP_CORS_ORIGIN is unset. OAuth endpoints will reject ALL cross-origin requests until you set the env var (comma-separated origins).',
|
||
);
|
||
}
|
||
const corsOAuthOptions: cors.CorsOptions = {
|
||
origin: resolveCorsOrigin(corsAllowlistOAuth),
|
||
credentials: false,
|
||
methods: ['GET', 'POST', 'OPTIONS'],
|
||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||
};
|
||
app.use('/mcp', cors(corsOAuthOptions));
|
||
app.use('/token', cors(corsOAuthOptions));
|
||
app.use('/authorize', cors(corsOAuthOptions));
|
||
app.use('/register', cors(corsOAuthOptions));
|
||
app.use('/revoke', cors(corsOAuthOptions));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Custom client_credentials handler (before mcpAuthRouter)
|
||
// SDK's token handler only supports authorization_code and refresh_token
|
||
// ---------------------------------------------------------------------------
|
||
const ccRateLimiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 50,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
message: { error: 'too_many_requests', error_description: 'Rate limit exceeded. Try again in 15 minutes.' },
|
||
});
|
||
|
||
// Magic-link rate limiter: 10 requests/min/IP. The bootstrap token is
|
||
// 64-char hex (unguessable) so brute-forcing is computationally
|
||
// infeasible — but a misconfigured client looping on /admin/auth/:bad
|
||
// could DoS the server's CPU on sha256 + the inline HTML response.
|
||
// Defense-in-depth on the highest-privileged URL the server exposes.
|
||
const adminAuthRateLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 10,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
message: 'Too many magic-link attempts. Wait a minute before trying again.',
|
||
});
|
||
|
||
app.post('/token', ccRateLimiter, express.urlencoded({ extended: false }), async (req, res, next) => {
|
||
if (req.body?.grant_type !== 'client_credentials') {
|
||
return next(); // Fall through to confidential-client handler or SDK
|
||
}
|
||
|
||
try {
|
||
const { client_id, client_secret, scope } = req.body;
|
||
if (!client_id || !client_secret) {
|
||
res.status(400).json({ error: 'invalid_request', error_description: 'client_id and client_secret required' });
|
||
return;
|
||
}
|
||
|
||
const tokens = await oauthProvider.exchangeClientCredentials(client_id, client_secret, scope);
|
||
res.json(tokens);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||
res.status(400).json({ error: 'invalid_grant', error_description: msg });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// v0.37.7.0 #1166: Custom authorization_code + refresh_token handler for
|
||
// CONFIDENTIAL clients. The MCP SDK's clientAuth middleware does plaintext
|
||
// `client.client_secret !== presented_secret` compare; we store
|
||
// SHA-256 hashes, so the SDK's compare always fails for confidential
|
||
// clients. This middleware verifies the secret hash ourselves before
|
||
// calling the provider's exchange methods directly.
|
||
//
|
||
// Public clients (token_endpoint_auth_method='none') fall through to
|
||
// the SDK's handler — the v0.34.1.0 PKCE path stays canonical.
|
||
// ---------------------------------------------------------------------------
|
||
app.post('/token', ccRateLimiter, async (req, res, next) => {
|
||
const grantType = req.body?.grant_type;
|
||
if (grantType !== 'authorization_code' && grantType !== 'refresh_token') {
|
||
return next();
|
||
}
|
||
|
||
// Detect confidential auth: either client_secret in body
|
||
// (client_secret_post) OR Authorization: Basic header
|
||
// (client_secret_basic). Public PKCE clients omit both.
|
||
const bodySecret: string | undefined = req.body?.client_secret;
|
||
let clientId: string | undefined = req.body?.client_id;
|
||
let presentedSecret: string | undefined = bodySecret;
|
||
const authHeader = (req.headers.authorization ?? '').toString();
|
||
if (!presentedSecret && authHeader.startsWith('Basic ')) {
|
||
try {
|
||
const decoded = Buffer.from(authHeader.slice('Basic '.length), 'base64').toString('utf8');
|
||
const idx = decoded.indexOf(':');
|
||
if (idx > -1) {
|
||
clientId ||= decodeURIComponent(decoded.slice(0, idx));
|
||
presentedSecret = decodeURIComponent(decoded.slice(idx + 1));
|
||
}
|
||
} catch {
|
||
// Malformed Basic header → falls through; SDK will reject
|
||
}
|
||
}
|
||
if (!clientId || !presentedSecret) {
|
||
return next(); // Public client path; SDK handles.
|
||
}
|
||
|
||
try {
|
||
const client = await oauthProvider.verifyConfidentialClientSecret(clientId, presentedSecret);
|
||
let tokens;
|
||
if (grantType === 'authorization_code') {
|
||
const code = req.body.code;
|
||
const redirectUri = req.body.redirect_uri;
|
||
const codeVerifier = req.body.code_verifier;
|
||
if (!code) {
|
||
res.status(400).json({ error: 'invalid_request', error_description: 'code required' });
|
||
return;
|
||
}
|
||
tokens = await oauthProvider.exchangeAuthorizationCode(client, code, codeVerifier, redirectUri);
|
||
} else {
|
||
const refreshToken = req.body.refresh_token;
|
||
const scopeParam = typeof req.body.scope === 'string' ? req.body.scope.split(/\s+/) : undefined;
|
||
if (!refreshToken) {
|
||
res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token required' });
|
||
return;
|
||
}
|
||
tokens = await oauthProvider.exchangeRefreshToken(client, refreshToken, scopeParam);
|
||
}
|
||
res.json(tokens);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||
// RFC 6749: invalid_client for auth failures, invalid_grant for
|
||
// code/token problems. "Invalid client" → 401; everything else 400.
|
||
if (msg === 'Invalid client' || msg === 'Client has been revoked') {
|
||
res.status(401).json({ error: 'invalid_client', error_description: msg });
|
||
} else {
|
||
res.status(400).json({ error: 'invalid_grant', error_description: msg });
|
||
}
|
||
}
|
||
});
|
||
|
||
// The SDK's /revoke handler compares the presented secret with
|
||
// client.client_secret as plaintext. GBrain stores only a SHA-256 hash, so
|
||
// confidential clients need the same hash-aware validation used above for
|
||
// authorization_code and refresh_token exchanges. Public clients present no
|
||
// secret and continue through to the SDK's PKCE-compatible handler.
|
||
app.post('/revoke', ccRateLimiter, express.urlencoded({ extended: false }), async (req, res, next) => {
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
|
||
const rawClientId: unknown = req.body?.client_id;
|
||
const rawBodySecret: unknown = req.body?.client_secret;
|
||
const authHeader = (req.headers.authorization ?? '').toString();
|
||
|
||
// RFC 6749 §2.3: one client-authentication method per request. Reject
|
||
// duplicates/arrays from express.urlencoded rather than letting them reach
|
||
// hashToken() as non-strings and become a misleading invalid_client error.
|
||
const hasBasicAuth = /^Basic\b/i.test(authHeader);
|
||
if (
|
||
(rawClientId !== undefined && typeof rawClientId !== 'string') ||
|
||
(rawBodySecret !== undefined && typeof rawBodySecret !== 'string') ||
|
||
(hasBasicAuth && (rawClientId !== undefined || rawBodySecret !== undefined))
|
||
) {
|
||
res.status(400).json({ error: 'invalid_request', error_description: 'Malformed or mixed client authentication' });
|
||
return;
|
||
}
|
||
|
||
let clientId = typeof rawClientId === 'string' ? rawClientId : undefined;
|
||
let presentedSecret = typeof rawBodySecret === 'string' && rawBodySecret.length > 0
|
||
? rawBodySecret
|
||
: undefined;
|
||
if (hasBasicAuth) {
|
||
try {
|
||
const match = authHeader.match(/^Basic\s+([^\s]+)$/i);
|
||
if (!match) throw new Error('Malformed Basic authentication');
|
||
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
|
||
const idx = decoded.indexOf(':');
|
||
if (idx < 1) throw new Error('Malformed Basic authentication');
|
||
clientId = decodeURIComponent(decoded.slice(0, idx).replace(/\+/g, ' '));
|
||
presentedSecret = decodeURIComponent(decoded.slice(idx + 1).replace(/\+/g, ' '));
|
||
if (!presentedSecret) throw new Error('Malformed Basic authentication');
|
||
} catch {
|
||
res.setHeader('WWW-Authenticate', 'Basic realm="gbrain"');
|
||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client' });
|
||
return;
|
||
}
|
||
}
|
||
if (!clientId || !presentedSecret) return next();
|
||
|
||
const parsedRequest = OAuthTokenRevocationRequestSchema.safeParse(req.body);
|
||
if (!parsedRequest.success || parsedRequest.data.token.length === 0) {
|
||
res.status(400).json({ error: 'invalid_request', error_description: 'Valid token required' });
|
||
return;
|
||
}
|
||
|
||
let client;
|
||
try {
|
||
client = await oauthProvider.verifyConfidentialClientSecret(clientId, presentedSecret);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : '';
|
||
if (msg === 'Invalid client' || msg === 'Client has been revoked') {
|
||
if (hasBasicAuth) res.setHeader('WWW-Authenticate', 'Basic realm="gbrain"');
|
||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client' });
|
||
return;
|
||
}
|
||
console.error('[serve-http] revoke client verification failed:', msg || 'Unknown error');
|
||
const retryable = isRetryableError(e);
|
||
res.status(retryable ? 503 : 500).json({
|
||
error: retryable ? 'temporarily_unavailable' : 'server_error',
|
||
error_description: retryable ? 'Token revocation temporarily unavailable' : 'Token revocation failed',
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await oauthProvider.revokeToken(client, parsedRequest.data);
|
||
// RFC 7009 §2.2: successful revocation, including an unknown token, is 200.
|
||
res.status(200).end();
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||
console.error('[serve-http] token revocation failed:', msg);
|
||
const retryable = isRetryableError(e);
|
||
res.status(retryable ? 503 : 500).json({
|
||
error: retryable ? 'temporarily_unavailable' : 'server_error',
|
||
error_description: retryable ? 'Token revocation temporarily unavailable' : 'Token revocation failed',
|
||
});
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MCP SDK Auth Router (OAuth endpoints)
|
||
// ---------------------------------------------------------------------------
|
||
// The issuer URL goes into discovery metadata + token iss claims. It MUST
|
||
// match the URL clients actually hit, or strict OAuth clients reject tokens
|
||
// (RFC 8414 §3.3). Honor --public-url for production deployments behind
|
||
// reverse proxies / tunnels; default to localhost for dev.
|
||
const issuerUrl = new URL(publicUrl || `http://localhost:${port}`);
|
||
|
||
// F9: cookie `secure` flag honors both the request's TLS state (req.secure
|
||
// is set when express trust-proxy lands an X-Forwarded-Proto: https) AND
|
||
// the operator's declared issuer protocol (so a Cloudflare-tunnel deploy
|
||
// where the connection inside the tunnel looks like http but the public
|
||
// URL is https still tags cookies Secure). Without this, an attacker on
|
||
// the network path could MITM the admin cookie over plaintext.
|
||
const adminCookie = (req: Request, maxAge: number) => ({
|
||
httpOnly: true,
|
||
sameSite: 'strict' as const,
|
||
secure: req.secure || issuerUrl.protocol === 'https:',
|
||
maxAge,
|
||
path: '/admin',
|
||
});
|
||
|
||
const authRouterOptions: any = {
|
||
provider: oauthProvider,
|
||
issuerUrl,
|
||
// v0.28: scopesSupported sourced from ALLOWED_SCOPES_LIST so MCP clients
|
||
// (Claude Desktop, ChatGPT, Perplexity) can discover sources_admin and
|
||
// users_admin via /.well-known/oauth-authorization-server. The legacy
|
||
// ['read','write','admin'] list left those new scopes invisible.
|
||
scopesSupported: [...ALLOWED_SCOPES_LIST],
|
||
resourceName: 'GBrain MCP Server',
|
||
};
|
||
|
||
// F12: DCR disable lives on the provider's constructor option above. The
|
||
// SDK's mcpAuthRouter reads provider.clientsStore once and only wires up
|
||
// /register when the store exposes registerClient — so passing dcrDisabled
|
||
// to the constructor is sufficient. No monkey-patching here.
|
||
|
||
const authRouter = mcpAuthRouter(authRouterOptions);
|
||
|
||
// Patch the SDK's OAuth metadata to include client_credentials grant type.
|
||
// The SDK hardcodes ['authorization_code', 'refresh_token'] — we intercept
|
||
// the response and add client_credentials before it reaches the client.
|
||
app.use((req, res, next) => {
|
||
if (req.path === '/.well-known/oauth-authorization-server' && req.method === 'GET') {
|
||
const origJson = res.json.bind(res);
|
||
(res as any).json = (body: any) => {
|
||
if (body?.grant_types_supported && !body.grant_types_supported.includes('client_credentials')) {
|
||
body.grant_types_supported.push('client_credentials');
|
||
}
|
||
if (body?.token_endpoint_auth_methods_supported) {
|
||
for (const method of ['client_secret_basic', 'none']) {
|
||
if (!body.token_endpoint_auth_methods_supported.includes(method)) {
|
||
body.token_endpoint_auth_methods_supported.push(method);
|
||
}
|
||
}
|
||
}
|
||
if (body?.revocation_endpoint_auth_methods_supported && !body.revocation_endpoint_auth_methods_supported.includes('client_secret_basic')) {
|
||
body.revocation_endpoint_auth_methods_supported.push('client_secret_basic');
|
||
}
|
||
return origJson(body);
|
||
};
|
||
}
|
||
next();
|
||
});
|
||
|
||
app.use(authRouter);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Health check — liveness only. Full engine stats live at
|
||
// /admin/api/full-stats (requireAdmin). See probeLiveness above for the why.
|
||
// ---------------------------------------------------------------------------
|
||
app.get('/health', async (_req, res) => {
|
||
const result = await probeLiveness(sql, config.engine || 'pglite', VERSION);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Admin authentication (cookie-based)
|
||
// ---------------------------------------------------------------------------
|
||
// v0.40 D15.5: safeHexEqual extracted to src/core/timing-safe.ts so the new
|
||
// /webhooks/github HMAC verifier reuses the same constant-time compare.
|
||
// POST /admin/login — JSON body with token (for programmatic/UI login)
|
||
app.post('/admin/login', express.json(), (req, res) => {
|
||
const token = req.body?.token;
|
||
if (!token || typeof token !== 'string') {
|
||
res.status(400).json({ error: 'Token required' });
|
||
return;
|
||
}
|
||
|
||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||
if (!safeHexEqual(tokenHash, bootstrapHash)) {
|
||
res.status(401).json({ error: 'Invalid token. Check your terminal output.' });
|
||
return;
|
||
}
|
||
|
||
const sessionId = randomBytes(32).toString('hex');
|
||
const expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
|
||
adminSessions.set(sessionId, expiresAt);
|
||
|
||
res.cookie('gbrain_admin', sessionId, adminCookie(req, 24 * 60 * 60 * 1000));
|
||
res.json({ status: 'authenticated' });
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Magic-link nonce store (single-use) — D11 + D12
|
||
//
|
||
// Trust model (codex review pushback resolved this):
|
||
// - Bootstrap token is the long-term server admin secret. Printed to
|
||
// stderr at startup; lives in operator's terminal scrollback only.
|
||
// - Magic-link URLs use one-time NONCES (not the bootstrap token).
|
||
// Agent calls POST /admin/api/issue-magic-link with the bootstrap
|
||
// token in Authorization: Bearer to mint a nonce. Nonce expires in
|
||
// 5 minutes if unredeemed; consumed on first redemption.
|
||
// - Bootstrap token never appears in a URL → no leakage via browser
|
||
// history, proxy access logs, or Referer headers.
|
||
// - Cookie sessions are HttpOnly + SameSite=Strict, but the bootstrap
|
||
// token itself is never client-side-readable JS state (no
|
||
// localStorage/sessionStorage cache — D12).
|
||
//
|
||
// Memory bound: nonces auto-purged on expiry sweep + LRU cap of 1000
|
||
// entries (an attacker minting millions can't OOM the server).
|
||
// ---------------------------------------------------------------------------
|
||
const magicLinkNonces = new Map<string, number>(); // nonce → expiresAt
|
||
const consumedNonces = new Set<string>();
|
||
const NONCE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||
const NONCE_LRU_CAP = 1000;
|
||
|
||
// Best-effort GC: remove expired entries on each issue/redeem call.
|
||
function pruneExpiredNonces() {
|
||
const now = Date.now();
|
||
for (const [nonce, expiresAt] of magicLinkNonces) {
|
||
if (expiresAt < now) magicLinkNonces.delete(nonce);
|
||
}
|
||
// F10: bound the live-nonce store too. An attacker with the bootstrap
|
||
// token (or a misbehaving agent) could mint nonces faster than they
|
||
// expire. Map iteration order is insertion order, so dropping from the
|
||
// front gives a simple FIFO eviction matching the consumedNonces pattern.
|
||
if (magicLinkNonces.size > NONCE_LRU_CAP) {
|
||
const drop = magicLinkNonces.size - NONCE_LRU_CAP;
|
||
const it = magicLinkNonces.keys();
|
||
for (let i = 0; i < drop; i++) magicLinkNonces.delete(it.next().value as string);
|
||
}
|
||
// Cap consumedNonces growth — drop oldest entries past the LRU cap.
|
||
if (consumedNonces.size > NONCE_LRU_CAP) {
|
||
const drop = consumedNonces.size - NONCE_LRU_CAP;
|
||
const it = consumedNonces.values();
|
||
for (let i = 0; i < drop; i++) consumedNonces.delete(it.next().value as string);
|
||
}
|
||
}
|
||
|
||
// POST /admin/api/issue-magic-link — agent-callable mint endpoint.
|
||
// Auth: Authorization: Bearer <bootstrapToken>. Returns one-time nonce.
|
||
app.post('/admin/api/issue-magic-link', express.json(), (req: Request, res: Response) => {
|
||
const auth = (req.headers.authorization || '') as string;
|
||
const m = auth.match(/^Bearer\s+(\S+)$/i);
|
||
if (!m) {
|
||
res.status(401).json({ error: 'Authorization: Bearer <bootstrap-token> required' });
|
||
return;
|
||
}
|
||
const tokenHash = createHash('sha256').update(m[1]).digest('hex');
|
||
if (!safeHexEqual(tokenHash, bootstrapHash)) {
|
||
res.status(401).json({ error: 'Invalid bootstrap token' });
|
||
return;
|
||
}
|
||
pruneExpiredNonces();
|
||
const nonce = randomBytes(32).toString('hex');
|
||
magicLinkNonces.set(nonce, Date.now() + NONCE_TTL_MS);
|
||
const baseUrl = publicUrl || `http://localhost:${port}`;
|
||
res.json({ url: `${baseUrl}/admin/auth/${nonce}`, expires_in: NONCE_TTL_MS / 1000 });
|
||
});
|
||
|
||
// GET /admin/auth/:nonce — single-use magic link redemption.
|
||
// Browser hits it, server validates the nonce (exists + unconsumed +
|
||
// unexpired), marks consumed, sets cookie, redirects to dashboard.
|
||
// Rate-limited at 10/min/IP to harden against DoS via bad-token loops.
|
||
app.get('/admin/auth/:token', adminAuthRateLimiter, (req: Request, res: Response) => {
|
||
const nonce = String(req.params.token ?? '');
|
||
pruneExpiredNonces();
|
||
|
||
const expiresAt = magicLinkNonces.get(nonce);
|
||
const isValid = !!nonce && !!expiresAt && expiresAt > Date.now() && !consumedNonces.has(nonce);
|
||
|
||
if (!isValid) {
|
||
res.status(401).send(`<!DOCTYPE html>
|
||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>GBrain</title>
|
||
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0f;color:#e0e0e0;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||
.box{max-width:400px;padding:32px;text-align:left}
|
||
.logo{font-size:28px;font-weight:600;margin-bottom:24px}
|
||
.msg{color:#888;font-size:14px;line-height:1.6;margin-bottom:20px}
|
||
.hint{background:rgba(136,170,255,0.08);border:1px solid rgba(136,170,255,0.2);border-radius:8px;padding:14px 16px;font-size:13px;line-height:1.5;color:#888}
|
||
.hint b{color:#e0e0e0}
|
||
.prompt{background:rgba(0,0,0,0.3);border-radius:6px;padding:8px 12px;margin-top:8px;font-family:monospace;font-size:12px;color:#88aaff}
|
||
</style></head><body><div class="box">
|
||
<div class="logo">GBrain</div>
|
||
<div class="msg">⚠️ This admin link has expired, was already used, or the server has restarted.</div>
|
||
<div class="hint"><b>Get a fresh link from your AI agent:</b>
|
||
<div class="prompt">“Give me the GBrain admin login link”</div>
|
||
</div></div></body></html>`);
|
||
return;
|
||
}
|
||
|
||
// Consume the nonce — it's single-use, second click will fail.
|
||
magicLinkNonces.delete(nonce);
|
||
consumedNonces.add(nonce);
|
||
|
||
const sessionId = randomBytes(32).toString('hex');
|
||
const sessionExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days for magic link
|
||
adminSessions.set(sessionId, sessionExpiresAt);
|
||
|
||
res.cookie('gbrain_admin', sessionId, adminCookie(req, 7 * 24 * 60 * 60 * 1000));
|
||
res.redirect('/admin/');
|
||
});
|
||
|
||
// Admin auth middleware
|
||
function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||
const sessionId = (req.cookies as Record<string, string>)?.gbrain_admin;
|
||
if (!sessionId || !adminSessions.has(sessionId)) {
|
||
res.status(401).json({ error: 'Admin authentication required' });
|
||
return;
|
||
}
|
||
const expiresAt = adminSessions.get(sessionId)!;
|
||
if (Date.now() > expiresAt) {
|
||
adminSessions.delete(sessionId);
|
||
res.status(401).json({ error: 'Session expired' });
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Admin API endpoints
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Sign-out-everywhere: nuke ALL active admin sessions in-memory. Every
|
||
// browser/tab fails its next request, gets 401, redirects to login.
|
||
// The bootstrap token itself is unaffected (still valid for new
|
||
// magic-link mints) — this only revokes existing cookie sessions.
|
||
app.post('/admin/api/sign-out-everywhere', requireAdmin, (_req: Request, res: Response) => {
|
||
const count = adminSessions.size;
|
||
adminSessions.clear();
|
||
res.json({ revoked_sessions: count });
|
||
});
|
||
|
||
app.get('/admin/api/agents', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
// Unified view: OAuth clients + legacy API keys
|
||
const oauthClients = await sql`
|
||
SELECT c.client_id as id, c.client_name as name, 'oauth' as auth_type,
|
||
c.grant_types, c.scope, c.created_at, c.token_ttl,
|
||
CASE WHEN c.deleted_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
|
||
(SELECT max(created_at) FROM mcp_request_log WHERE token_name = c.client_id) as last_used_at,
|
||
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id) as total_requests,
|
||
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = c.client_id AND created_at > now() - interval '24 hours') as requests_today
|
||
FROM oauth_clients c ORDER BY c.created_at DESC
|
||
`;
|
||
const legacyKeys = await sql`
|
||
SELECT a.id, a.name, 'api_key' as auth_type,
|
||
'{"bearer"}' as grant_types, 'read write admin' as scope, a.created_at, null as token_ttl,
|
||
CASE WHEN a.revoked_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status,
|
||
a.last_used_at,
|
||
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name) as total_requests,
|
||
(SELECT count(*)::int FROM mcp_request_log WHERE token_name = a.name AND created_at > now() - interval '24 hours') as requests_today
|
||
FROM access_tokens a ORDER BY a.created_at DESC
|
||
`;
|
||
res.json([...oauthClients, ...legacyKeys]);
|
||
} catch (e) {
|
||
res.status(503).json({ error: 'service_unavailable' });
|
||
}
|
||
});
|
||
|
||
// v0.38 Slice 4 — per-OAuth-client agent spend viewer. Pre-computes today's
|
||
// spend (committed + pending reservations) per client so the Agents tab
|
||
// can render a "$X / $Y today" cell. Read-side endpoint only — no mutation.
|
||
// Falls back to an empty array on pre-v0.38 brains where mcp_spend_log
|
||
// exists but agent dispatch hasn't recorded anything.
|
||
app.get('/admin/api/agents/spend', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const rows = await queryAgentClientSpend(engine);
|
||
res.json(rows);
|
||
} catch (e) {
|
||
// Pre-v0.38 brains: tables may not exist yet. Return empty so the UI
|
||
// renders gracefully instead of erroring.
|
||
res.json([]);
|
||
}
|
||
});
|
||
|
||
app.get('/admin/api/stats', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const [clients] = await sql`SELECT count(*)::int as count FROM oauth_clients`;
|
||
const [tokens] = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE token_type = 'access' AND expires_at > ${Math.floor(Date.now() / 1000)}`;
|
||
const [requests] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE created_at > now() - interval '24 hours'`;
|
||
const [apiKeys] = await sql`SELECT count(*)::int as count FROM access_tokens WHERE revoked_at IS NULL`;
|
||
res.json({
|
||
connected_agents: (clients as any).count,
|
||
active_tokens: (tokens as any).count,
|
||
active_api_keys: (apiKeys as any).count,
|
||
requests_today: (requests as any).count,
|
||
});
|
||
} catch {
|
||
res.status(503).json({ error: 'service_unavailable' });
|
||
}
|
||
});
|
||
|
||
app.get('/admin/api/health-indicators', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const [expiring] = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE token_type = 'access' AND expires_at BETWEEN ${now} AND ${now + 86400}`;
|
||
const [errors] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE status != 'success' AND created_at > now() - interval '24 hours'`;
|
||
const [total] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE created_at > now() - interval '24 hours'`;
|
||
const errorRate = (total as any).count > 0 ? ((errors as any).count / (total as any).count * 100).toFixed(1) : '0';
|
||
res.json({
|
||
expiring_soon: (expiring as any).count,
|
||
error_rate: `${errorRate}%`,
|
||
});
|
||
} catch {
|
||
res.status(503).json({ error: 'service_unavailable' });
|
||
}
|
||
});
|
||
|
||
// Full engine stats. v0.28.10 moved this off /health (which is now liveness
|
||
// only — see probeLiveness) so dashboards needing page_count / chunk_count
|
||
// / etc. authenticate as admin and call this endpoint. probeHealth races
|
||
// engine.getStats() against HEALTH_TIMEOUT_MS so a saturated pool returns
|
||
// 503 rather than hanging.
|
||
app.get('/admin/api/full-stats', requireAdmin, async (_req: Request, res: Response) => {
|
||
const result = await probeHealth(engine, config.engine || 'pglite', VERSION);
|
||
res.status(result.status).json(result.body);
|
||
});
|
||
|
||
// v0.41 D2 — live jobs dashboard data. Shares readSnapshot() with the
|
||
// TTY `gbrain jobs watch` command so the two surfaces stay 1:1.
|
||
app.get('/admin/api/jobs/watch', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const { readSnapshot } = await import('./jobs-watch.ts');
|
||
const snap = await readSnapshot(engine);
|
||
res.json(snap);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
res.status(500).json({ error: msg });
|
||
}
|
||
});
|
||
|
||
// v0.36.1.0 (T15 / E6 / D23) — Calibration tab data endpoints.
|
||
// Server-rendered SVG charts; admin SPA renders via TrustedSVG wrapper.
|
||
// v0.36.1.0 (TD3) — pattern drill-down. Returns the source takes that
|
||
// produced the pattern statement at index `id` of the active profile.
|
||
// v0.36.1.0 ship state: returns the top N takes in the holder's overall
|
||
// takes table, sorted by weight desc. v0.37+ will store per-pattern
|
||
// source_take_ids on calibration_profiles_patterns so the drill-down
|
||
// shows the EXACT takes that drove the pattern.
|
||
app.get('/admin/api/calibration/pattern/:id', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { getLatestProfile } = await import('./calibration.ts');
|
||
const holder = (req.query.holder as string) || 'garry';
|
||
const profile = await getLatestProfile(engine, { holder });
|
||
if (!profile) {
|
||
res.status(404).json({ error: 'no_profile' });
|
||
return;
|
||
}
|
||
const rawId = req.params.id;
|
||
const idStr = Array.isArray(rawId) ? rawId[0] : rawId;
|
||
const idx = Number.parseInt(idStr ?? '', 10) - 1;
|
||
if (!Number.isFinite(idx) || idx < 0 || idx >= profile.pattern_statements.length) {
|
||
res.status(400).json({ error: 'invalid_pattern_index', max: profile.pattern_statements.length });
|
||
return;
|
||
}
|
||
const statement = profile.pattern_statements[idx];
|
||
// v0.36.1.0 ship state: surface the top resolved takes for the
|
||
// holder as drill-down evidence. Per-pattern provenance is v0.37.
|
||
const takes = await engine.executeRaw<{
|
||
id: string;
|
||
page_slug: string;
|
||
row_num: number;
|
||
claim: string;
|
||
weight: number;
|
||
resolved_quality: string | null;
|
||
since_date: string | null;
|
||
}>(
|
||
// `takes` has no page_slug column — it comes from the joined page.
|
||
// id::text — it's a BIGSERIAL (bigint); res.json() below can't serialize a
|
||
// raw bigint ("cannot serialize BigInt"), so project it as a string.
|
||
`SELECT t.id::text AS id, p.slug AS page_slug, t.row_num, t.claim, t.weight, t.resolved_quality, t.since_date
|
||
FROM takes t JOIN pages p ON p.id = t.page_id
|
||
WHERE t.holder = $1 AND t.active = true AND t.resolved_at IS NOT NULL
|
||
ORDER BY t.weight DESC, t.since_date DESC
|
||
LIMIT 25`,
|
||
[holder],
|
||
);
|
||
res.json({
|
||
pattern_statement: statement,
|
||
pattern_index: idx + 1,
|
||
holder,
|
||
provenance_note: 'v0.36.1.0 ship state shows top-25 resolved takes for this holder; per-pattern source_take_ids land in v0.37.',
|
||
takes,
|
||
});
|
||
} catch (err) {
|
||
res.status(500).json({ error: err instanceof Error ? err.message : 'unknown' });
|
||
}
|
||
});
|
||
|
||
app.get('/admin/api/calibration/profile', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { getLatestProfile } = await import('./calibration.ts');
|
||
const holder = (req.query.holder as string) || 'garry';
|
||
const profile = await getLatestProfile(engine, { holder });
|
||
res.json(profile);
|
||
} catch (err) {
|
||
res.status(500).json({ error: err instanceof Error ? err.message : 'unknown' });
|
||
}
|
||
});
|
||
|
||
app.get('/admin/api/calibration/charts/:type', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { getLatestProfile } = await import('./calibration.ts');
|
||
const {
|
||
renderBrierTrend,
|
||
renderDomainBars,
|
||
renderAbandonedThreadsCard,
|
||
renderPatternStatementsCard,
|
||
} = await import('../core/calibration/svg-renderer.ts');
|
||
const holder = (req.query.holder as string) || 'garry';
|
||
const type = req.params.type;
|
||
const profile = await getLatestProfile(engine, { holder });
|
||
|
||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||
res.setHeader('Cache-Control', 'private, max-age=60');
|
||
|
||
if (type === 'brier-trend') {
|
||
// v0.36.1.0 ship state: 1-point series from the active profile. A
|
||
// proper 90-day time series will read from calibration_profiles
|
||
// generated_at history in v0.37 once we have multiple snapshots.
|
||
const series = profile?.brier !== null && profile?.brier !== undefined
|
||
// generated_at comes back from the engine as a Date (TIMESTAMPTZ), not
|
||
// a string — `.slice` would throw. Normalize to a YYYY-MM-DD string.
|
||
? [{ date: new Date(profile.generated_at).toISOString().slice(0, 10), brier: profile.brier }]
|
||
: [];
|
||
return res.send(renderBrierTrend({ series }));
|
||
}
|
||
if (type === 'domain-bars') {
|
||
// v0.36.1.0 ship state: domain_scorecards JSONB is a placeholder
|
||
// (per-domain rendering comes when batchGetTakesScorecards lands in
|
||
// a follow-up). Render empty for now.
|
||
return res.send(renderDomainBars({ bars: [] }));
|
||
}
|
||
if (type === 'pattern-statements') {
|
||
return res.send(
|
||
renderPatternStatementsCard(
|
||
(profile?.pattern_statements ?? []).map((text: string) => ({ text })),
|
||
),
|
||
);
|
||
}
|
||
if (type === 'abandoned-threads') {
|
||
// v0.36.1.0 ship state: pull abandoned threads inline via a small
|
||
// SQL query (the doctor check counts them; this surfaces details).
|
||
const rows = await engine.executeRaw<{
|
||
id: number;
|
||
page_slug: string;
|
||
claim: string;
|
||
weight: number;
|
||
since_date: string;
|
||
}>(
|
||
// `takes` has no page_slug column — it comes from the joined page.
|
||
// since_date is TEXT and may be month-precision ('YYYY-MM'); '2026-06'::date
|
||
// throws "invalid input syntax for type date", so normalize to the 1st
|
||
// before casting.
|
||
`SELECT t.id, p.slug AS page_slug, t.claim, t.weight, t.since_date
|
||
FROM takes t JOIN pages p ON p.id = t.page_id
|
||
WHERE t.active = true AND t.resolved_at IS NULL AND t.superseded_by IS NULL
|
||
AND t.weight >= 0.7
|
||
AND (t.since_date || CASE WHEN length(t.since_date) = 7 THEN '-01' ELSE '' END)::date
|
||
< (now() - INTERVAL '12 months')
|
||
ORDER BY t.since_date ASC
|
||
LIMIT 5`,
|
||
);
|
||
const now = new Date();
|
||
const threads = rows.map(r => {
|
||
const since = new Date((r.since_date.length === 7 ? r.since_date + '-15' : r.since_date));
|
||
const monthsSilent = Math.max(0, Math.floor((now.getTime() - since.getTime()) / (1000 * 60 * 60 * 24 * 30)));
|
||
return {
|
||
takeId: r.id,
|
||
pageSlug: r.page_slug,
|
||
claim: r.claim,
|
||
monthsSilent,
|
||
conviction: r.weight,
|
||
};
|
||
});
|
||
return res.send(renderAbandonedThreadsCard(threads));
|
||
}
|
||
res.status(400).json({ error: 'unknown_chart_type', supported: ['brier-trend', 'domain-bars', 'pattern-statements', 'abandoned-threads'] });
|
||
return;
|
||
} catch (err) {
|
||
res.status(500).json({ error: err instanceof Error ? err.message : 'unknown' });
|
||
return;
|
||
}
|
||
});
|
||
|
||
app.get('/admin/api/requests', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const page = parseInt(req.query.page as string) || 1;
|
||
const limit = 50;
|
||
const offset = (page - 1) * limit;
|
||
const agent = req.query.agent as string;
|
||
const operation = req.query.operation as string;
|
||
const status = req.query.status as string;
|
||
|
||
// Dynamic filtering: SqlQuery is deliberately scalar-only and does not
|
||
// support fragment composition (the prior `sql\`AND ... = ${v}\`` shape).
|
||
// Build the WHERE clause with positional placeholders + a params array.
|
||
// `WHERE 1=1` lets us always have a WHERE clause and conditionally
|
||
// append `AND col = $N` fragments — still parameterized, still escaped
|
||
// by the driver, no sql.unsafe.
|
||
const filters: string[] = [];
|
||
const params: (string | number)[] = [];
|
||
if (agent && agent !== 'all') {
|
||
filters.push(`AND token_name = $${params.length + 1}`);
|
||
params.push(agent);
|
||
}
|
||
if (operation && operation !== 'all') {
|
||
filters.push(`AND operation = $${params.length + 1}`);
|
||
params.push(operation);
|
||
}
|
||
if (status && status !== 'all') {
|
||
filters.push(`AND status = $${params.length + 1}`);
|
||
params.push(status);
|
||
}
|
||
const filterSql = filters.join(' ');
|
||
const limitParam = `$${params.length + 1}`;
|
||
const offsetParam = `$${params.length + 2}`;
|
||
|
||
const rows = await engine.executeRaw(
|
||
`SELECT id, token_name, COALESCE(agent_name, token_name) as agent_name,
|
||
operation, latency_ms, status, params, error_message, created_at
|
||
FROM mcp_request_log
|
||
WHERE 1=1 ${filterSql}
|
||
ORDER BY created_at DESC LIMIT ${limitParam} OFFSET ${offsetParam}`,
|
||
[...params, limit, offset],
|
||
);
|
||
const [countResult] = await engine.executeRaw<{ total: number }>(
|
||
`SELECT count(*)::int as total FROM mcp_request_log
|
||
WHERE 1=1 ${filterSql}`,
|
||
params,
|
||
);
|
||
res.json({ rows, total: countResult.total, page, pages: Math.ceil(countResult.total / limit) });
|
||
} catch {
|
||
res.status(503).json({ error: 'service_unavailable' });
|
||
}
|
||
});
|
||
|
||
// Legacy API keys (access_tokens table)
|
||
app.get('/admin/api/api-keys', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const keys = await sql`
|
||
SELECT id, name, created_at, last_used_at,
|
||
CASE WHEN revoked_at IS NOT NULL THEN 'revoked' ELSE 'active' END as status
|
||
FROM access_tokens ORDER BY created_at DESC
|
||
`;
|
||
res.json(keys);
|
||
} catch (e) {
|
||
res.status(503).json({ error: 'service_unavailable' });
|
||
}
|
||
});
|
||
|
||
app.post('/admin/api/api-keys', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||
try {
|
||
const { name } = req.body;
|
||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||
const { generateToken, hashToken } = await import('../core/utils.ts');
|
||
const token = generateToken('gbrain_');
|
||
const hash = hashToken(token);
|
||
const id = (await import('crypto')).randomUUID();
|
||
await sql`INSERT INTO access_tokens (id, name, token_hash) VALUES (${id}, ${name}, ${hash})`;
|
||
res.json({ name, token, id });
|
||
} catch (e) {
|
||
res.status(500).json({ error: e instanceof Error ? e.message : 'Failed to create API key' });
|
||
}
|
||
});
|
||
|
||
app.post('/admin/api/api-keys/revoke', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||
try {
|
||
const { name } = req.body;
|
||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||
await sql`UPDATE access_tokens SET revoked_at = now() WHERE name = ${name} AND revoked_at IS NULL`;
|
||
res.json({ revoked: true });
|
||
} catch (e) {
|
||
res.status(500).json({ error: e instanceof Error ? e.message : 'Revoke failed' });
|
||
}
|
||
});
|
||
|
||
// Register client from admin dashboard
|
||
app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||
try {
|
||
// v0.39.3.0 WARN-9 + CV12: accept BOTH `scopes` (admin SPA convention)
|
||
// AND `scope` (OAuth wire-format convention, singular). The pre-fix
|
||
// code destructured only `scopes` and used `scopes || 'read'` which:
|
||
// - Silently ignored `scope` requests (always defaulted to 'read')
|
||
// - Threw on array input because registerClientManual's parseScopeString
|
||
// calls .split(' ') which arrays don't have
|
||
// - Accepted `['read write']` (space-in-element bug shape codex flagged)
|
||
// and other malformed inputs
|
||
// normalizeScopesInput handles all four valid shapes (string, string[],
|
||
// missing, empty) and rejects the rest with a structured 400.
|
||
const { name, tokenTtl, grantTypes, redirectUris, tokenEndpointAuthMethod } = req.body;
|
||
const rawScopes = (req.body as Record<string, unknown>).scopes ?? (req.body as Record<string, unknown>).scope;
|
||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||
let scopeString: string;
|
||
try {
|
||
scopeString = normalizeScopesInput(rawScopes);
|
||
} catch (e) {
|
||
res.status(400).json({
|
||
error: 'invalid_scopes',
|
||
message: e instanceof Error ? e.message : String(e),
|
||
});
|
||
return;
|
||
}
|
||
const grants = Array.isArray(grantTypes) && grantTypes.length > 0 ? grantTypes : ['client_credentials'];
|
||
const uris = Array.isArray(redirectUris) ? redirectUris : [];
|
||
// v0.41.3 (T1+T4): validate token_endpoint_auth_method via shared
|
||
// ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS before reaching the provider.
|
||
// Pre-v0.41.3 this endpoint did INSERT (confidential) → UPDATE (NULL
|
||
// out secret_hash) for the 'none' case, which left a confidential
|
||
// row stranded if the UPDATE failed (codex F4). Atomic now: pass the
|
||
// method to registerClientManual and let it INSERT the correct row
|
||
// in a single statement.
|
||
let validatedAuthMethod: string | undefined;
|
||
try {
|
||
validatedAuthMethod = validateTokenEndpointAuthMethod(tokenEndpointAuthMethod);
|
||
} catch (e) {
|
||
res.status(400).json({
|
||
error: 'invalid_token_endpoint_auth_method',
|
||
message: e instanceof Error ? e.message : String(e),
|
||
});
|
||
return;
|
||
}
|
||
const result = await oauthProvider.registerClientManual(
|
||
name, grants, scopeString, uris, 'default', undefined, validatedAuthMethod,
|
||
);
|
||
// Set per-client TTL if specified
|
||
if (tokenTtl && Number(tokenTtl) > 0) {
|
||
await sql`UPDATE oauth_clients SET token_ttl = ${Number(tokenTtl)} WHERE client_id = ${result.clientId}`;
|
||
}
|
||
res.json({ ...result, tokenTtl: tokenTtl ? Number(tokenTtl) : null });
|
||
} catch (e) {
|
||
res.status(500).json({ error: e instanceof Error ? e.message : 'Registration failed' });
|
||
}
|
||
});
|
||
|
||
// Update client TTL
|
||
app.post('/admin/api/update-client-ttl', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||
try {
|
||
const { clientId, tokenTtl } = req.body;
|
||
if (!clientId) { res.status(400).json({ error: 'clientId required' }); return; }
|
||
const ttl = tokenTtl === null || tokenTtl === 0 ? null : Number(tokenTtl);
|
||
await sql`UPDATE oauth_clients SET token_ttl = ${ttl} WHERE client_id = ${clientId}`;
|
||
res.json({ updated: true, tokenTtl: ttl });
|
||
} catch (e) {
|
||
res.status(500).json({ error: e instanceof Error ? e.message : 'Update failed' });
|
||
}
|
||
});
|
||
|
||
// Revoke OAuth client
|
||
app.post('/admin/api/revoke-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||
try {
|
||
const { clientId } = req.body;
|
||
if (!clientId) { res.status(400).json({ error: 'clientId required' }); return; }
|
||
// Soft-delete the client
|
||
await sql`UPDATE oauth_clients SET deleted_at = now() WHERE client_id = ${clientId} AND deleted_at IS NULL`;
|
||
// Revoke all active tokens for this client
|
||
await sql`DELETE FROM oauth_tokens WHERE client_id = ${clientId}`;
|
||
res.json({ revoked: true });
|
||
} catch (e) {
|
||
res.status(500).json({ error: e instanceof Error ? e.message : 'Revoke failed' });
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSE live activity feed
|
||
// ---------------------------------------------------------------------------
|
||
app.get('/admin/events', requireAdmin, (req: Request, res: Response) => {
|
||
res.setHeader('Content-Type', 'text/event-stream');
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
res.flushHeaders();
|
||
|
||
sseClients.add(res);
|
||
req.on('close', () => sseClients.delete(res));
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Admin SPA static files (v0.36.x #1090)
|
||
// ---------------------------------------------------------------------------
|
||
// Two-tier resolution:
|
||
// 1. Dev path — admin/dist next to cwd. Vite rebuilds land here first,
|
||
// so devs hacking on the SPA see changes without re-running
|
||
// build-admin-embedded.
|
||
// 2. Binary path — `src/admin-embedded.ts` exports `ADMIN_ASSETS`, a
|
||
// manifest of request-path → resolved-path keyed by every file in
|
||
// admin/dist at generation time. Bun's `with { type: 'file' }` ESM
|
||
// imports resolve correctly inside the compiled binary, so a
|
||
// globally-installed `gbrain serve --http` actually serves /admin
|
||
// instead of 404. Pre-fix the cwd-relative path was the ONLY
|
||
// resolution path, and every fresh install of the compiled binary
|
||
// hit 404 on /admin (issue #1090).
|
||
const path = await import('path');
|
||
const fs = await import('fs');
|
||
const adminDistPath = path.join(process.cwd(), 'admin', 'dist');
|
||
const useDevPath = fs.existsSync(adminDistPath);
|
||
if (useDevPath) {
|
||
app.use('/admin', express.static(adminDistPath));
|
||
app.get('/admin/{*path}', (req: Request, res: Response, next: NextFunction) => {
|
||
if (req.path.startsWith('/admin/api/') || req.path === '/admin/events' || req.path === '/admin/login') {
|
||
return next();
|
||
}
|
||
res.sendFile(path.join(adminDistPath, 'index.html'));
|
||
});
|
||
} else {
|
||
// Embedded path. Read assets from the generated manifest. Cache the
|
||
// bytes per asset on first request — these never change for a given
|
||
// binary, so subsequent requests skip the fs read.
|
||
const { ADMIN_ASSETS, ADMIN_INDEX_HTML } = await import('../admin-embedded.ts');
|
||
const cache = new Map<string, Buffer>();
|
||
function loadAsset(asset: { path: string }): Buffer {
|
||
const hit = cache.get(asset.path);
|
||
if (hit) return hit;
|
||
const buf = fs.readFileSync(asset.path);
|
||
cache.set(asset.path, buf);
|
||
return buf;
|
||
}
|
||
app.get('/admin/{*path}', (req: Request, res: Response, next: NextFunction) => {
|
||
if (req.path.startsWith('/admin/api/') || req.path === '/admin/events' || req.path === '/admin/login') {
|
||
return next();
|
||
}
|
||
const hit = ADMIN_ASSETS[req.path];
|
||
if (hit) {
|
||
res.setHeader('Content-Type', hit.mime);
|
||
res.send(loadAsset(hit));
|
||
return;
|
||
}
|
||
// SPA fallback — every unmatched /admin/* route resolves to index.html
|
||
// so client-side routing takes over (login, dashboard, agents, ...).
|
||
if (ADMIN_INDEX_HTML) {
|
||
res.setHeader('Content-Type', ADMIN_INDEX_HTML.mime);
|
||
res.send(loadAsset(ADMIN_INDEX_HTML));
|
||
return;
|
||
}
|
||
res.status(404).send('admin SPA not available');
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MCP tool calls (bearer auth + scope enforcement)
|
||
// ---------------------------------------------------------------------------
|
||
const mcpOperations = operations.filter(op => !op.localOnly);
|
||
|
||
// v0.36.x #1076: MCP Streamable HTTP spec — GET /mcp opens an optional SSE
|
||
// backchannel for server-initiated messages. gbrain's transport is stateless
|
||
// and doesn't push server-initiated messages, so per spec we MUST return 405
|
||
// (not 404) so probing clients (claude.ai, etc.) recognize this as an MCP
|
||
// endpoint, not a missing route. Without this, clients display "endpoint not
|
||
// found" instead of "endpoint exists but no SSE channel."
|
||
app.get('/mcp', (_req: Request, res: Response) => {
|
||
res.set('Allow', 'POST, DELETE');
|
||
res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed' }, id: null });
|
||
});
|
||
|
||
app.post('/mcp', requireBearerAuth({ verifier: oauthProvider }), async (req: Request, res: Response) => {
|
||
const startTime = Date.now();
|
||
const authInfo = (req as any).auth as AuthInfo;
|
||
|
||
// Human-readable agent name is now threaded through AuthInfo by
|
||
// verifyAccessToken (which JOINs oauth_clients in its existing token
|
||
// SELECT). No per-request DB roundtrip needed. Falls back to clientId
|
||
// for legacy tokens or when the JOIN row's client_name is NULL.
|
||
const agentName = authInfo.clientName ?? authInfo.clientId;
|
||
|
||
// Create a fresh MCP server per request (stateless)
|
||
const server = new Server(
|
||
{ name: 'gbrain', version: VERSION },
|
||
{ capabilities: { tools: {} } },
|
||
);
|
||
|
||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||
// v0.28.10: log every JSON-RPC method, not just successful tools/call.
|
||
// Pre-fix, /admin/api/requests showed nothing for clients that only
|
||
// ever called tools/list, and the v0.26.3 persistence regression test
|
||
// asserting >= 2 rows after tools/list + tools/call was unreachable.
|
||
const latency = Date.now() - startTime;
|
||
try {
|
||
await executeRawJsonb(
|
||
engine,
|
||
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params)
|
||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||
[authInfo.clientId, agentName, 'tools/list', latency, 'success'],
|
||
[null],
|
||
);
|
||
} catch { /* best effort */ }
|
||
broadcastEvent({
|
||
agent: agentName,
|
||
operation: 'tools/list',
|
||
scopes: authInfo.scopes.join(','),
|
||
latency_ms: latency,
|
||
status: 'success',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
return {
|
||
tools: mcpOperations.map(op => ({
|
||
name: op.name,
|
||
description: op.description,
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: Object.fromEntries(
|
||
Object.entries(op.params).map(([k, v]) => [k, paramDefToSchema(v)]),
|
||
),
|
||
required: Object.entries(op.params).filter(([, v]) => v.required).map(([k]) => k),
|
||
},
|
||
})),
|
||
};
|
||
});
|
||
|
||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||
const { name, arguments: params } = request.params;
|
||
const op = mcpOperations.find(o => o.name === name);
|
||
if (!op) {
|
||
// v0.28.10: persist unknown-op attempts. Operators investigating
|
||
// misbehaving agents need to see the full attempt log, not just
|
||
// valid-op success/error.
|
||
const latency = Date.now() - startTime;
|
||
try {
|
||
await executeRawJsonb(
|
||
engine,
|
||
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, error_message, params)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||
[authInfo.clientId, agentName, name, latency, 'error', `unknown_operation: ${name}`],
|
||
[null],
|
||
);
|
||
} catch { /* best effort */ }
|
||
broadcastEvent({
|
||
agent: agentName,
|
||
operation: name,
|
||
scopes: authInfo.scopes.join(','),
|
||
latency_ms: latency,
|
||
status: 'error',
|
||
error: { code: 'unknown_operation', message: `Unknown: ${name}` },
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown_operation', message: `Unknown: ${name}` }) }], isError: true };
|
||
}
|
||
|
||
// Scope enforcement (v0.28: hasScope replaces exact-string-match so
|
||
// admin tokens satisfy any scope, write satisfies read, and the new
|
||
// sources_admin / users_admin scopes resolve through the same
|
||
// hierarchy. Plain string includes() at this site would have made
|
||
// sources_admin tokens look like they couldn't even read.)
|
||
const requiredScope = op.scope || 'read';
|
||
if (!hasScope(authInfo.scopes, requiredScope)) {
|
||
// v0.28.10: persist scope-rejected attempts. Same operator-visibility
|
||
// motivation as the unknown-op path — and it makes the v0.26.3
|
||
// persistence regression test reliable across both rejection paths.
|
||
const latency = Date.now() - startTime;
|
||
try {
|
||
await executeRawJsonb(
|
||
engine,
|
||
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, error_message, params)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||
[authInfo.clientId, agentName, name, latency, 'error', `insufficient_scope: requires '${requiredScope}'`],
|
||
[null],
|
||
);
|
||
} catch { /* best effort */ }
|
||
broadcastEvent({
|
||
agent: agentName,
|
||
operation: name,
|
||
scopes: authInfo.scopes.join(','),
|
||
latency_ms: latency,
|
||
status: 'error',
|
||
error: { code: 'insufficient_scope', message: `requires '${requiredScope}'` },
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
error: 'insufficient_scope',
|
||
message: `Operation ${name} requires '${requiredScope}' scope`,
|
||
your_scopes: authInfo.scopes,
|
||
}),
|
||
}],
|
||
isError: true,
|
||
};
|
||
}
|
||
|
||
// F8: redact request payload by default (declared keys only via the
|
||
// op's `params` allow-list; values + attacker-controlled key names
|
||
// never written to mcp_request_log or the SSE feed). --log-full-params
|
||
// bypasses this for operators debugging on their own laptop, with the
|
||
// startup warning printed earlier.
|
||
//
|
||
// D1 (v0.31 wave): mcp_request_log.params is JSONB. Pre-v0.31 wrote
|
||
// a JSON-string into that JSONB column via the postgres.js template
|
||
// tag's loose typing — readable but semantically wrong (params->>'op'
|
||
// would return the encoded string, not the value). Post-v0.31 we
|
||
// pass the OBJECT through executeRawJsonb with an explicit ::jsonb
|
||
// cast, so reads return real objects and `params->>'op'` returns
|
||
// 'tools/list'. Pre-existing string-shaped rows are normalized by
|
||
// migration v41 in src/core/migrate.ts.
|
||
const safeParamsSummary = summarizeMcpParams(name, params);
|
||
const logParamsObj: unknown = logFullParams
|
||
? (params || null)
|
||
: (safeParamsSummary || null);
|
||
const broadcastParams = logFullParams ? (params || {}) : safeParamsSummary;
|
||
|
||
// v0.31 (D12 / eE1): refactor the inlined op.handler call to go through
|
||
// src/mcp/dispatch.ts so HTTP MCP shares the same dispatch path as
|
||
// stdio MCP. The dispatcher does param validation, OperationContext
|
||
// build, error envelope unification, and (new) `_meta.brain_hot_memory`
|
||
// injection via the metaHook. HTTP-specific concerns (mcp_request_log
|
||
// persistence + SSE broadcast) stay here; the dispatcher returns the
|
||
// ToolResult and we read isError + _meta to pick the right branch.
|
||
const tokenAllowList = (authInfo as AuthInfo & { takesHoldersAllowList?: string[] }).takesHoldersAllowList
|
||
?? ['world'];
|
||
// v0.34.1 (#861, D13): AuthInfo.sourceId is now a real typed field
|
||
// populated from oauth_clients.source_id (migration v60 backfilled
|
||
// NULL → 'default'). Pre-fix this site cast through AuthInfo and
|
||
// fell back to GBRAIN_SOURCE env / 'default' — the silent-fallback
|
||
// path codex flagged in plan review. Post-v60, every OAuth client
|
||
// has source_id set; legacy bearer tokens default to 'default' in
|
||
// verifyAccessToken. The env-fallback is gone.
|
||
const tokenSourceId = authInfo.sourceId ?? 'default';
|
||
|
||
let toolResult: Awaited<ReturnType<typeof dispatchToolCall>>;
|
||
try {
|
||
toolResult = await dispatchToolCall(engine, name, params as Record<string, unknown> | undefined, {
|
||
remote: true,
|
||
takesHoldersAllowList: tokenAllowList,
|
||
sourceId: tokenSourceId,
|
||
metaHook: getBrainHotMemoryMeta,
|
||
// v0.31 follow-up fix: thread auth so the whoami op (and any
|
||
// future scope-aware handlers) can introspect the caller. The
|
||
// original D12/eE1 refactor moved dispatch into dispatchToolCall
|
||
// but forgot to pass authInfo; whoami fell through to the
|
||
// unknown_transport throw because ctx.auth was undefined.
|
||
auth: authInfo,
|
||
logger: {
|
||
info: (msg: string) => console.error(`[INFO] ${msg}`),
|
||
warn: (msg: string) => console.error(`[WARN] ${msg}`),
|
||
error: (msg: string) => console.error(`[ERROR] ${msg}`),
|
||
},
|
||
});
|
||
} catch (e) {
|
||
// dispatchToolCall absorbs OperationError + Error and returns
|
||
// isError:true; only an unexpected throw lands here. Treat as the
|
||
// F15 unified envelope. v0.31 wave (D1): mcp_request_log.params is
|
||
// JSONB — write the object via executeRawJsonb so reads return a
|
||
// real object, not a JSON-encoded string.
|
||
const latency = Date.now() - startTime;
|
||
const errorPayload = serializeError(e);
|
||
try {
|
||
await executeRawJsonb(
|
||
engine,
|
||
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, error_message, params)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||
[authInfo.clientId, agentName, name, latency, 'error', errorPayload.message],
|
||
[logParamsObj],
|
||
);
|
||
} catch { /* best effort */ }
|
||
broadcastEvent({
|
||
agent: agentName,
|
||
operation: name,
|
||
params: broadcastParams,
|
||
scopes: authInfo.scopes.join(','),
|
||
latency_ms: latency,
|
||
status: 'error',
|
||
error: errorPayload,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
return { content: [{ type: 'text', text: JSON.stringify({ error: errorPayload }) }], isError: true };
|
||
}
|
||
|
||
const latency = Date.now() - startTime;
|
||
if (toolResult.isError) {
|
||
// dispatchToolCall serializes the error into the content text;
|
||
// for the audit log we re-extract a message string for the
|
||
// mcp_request_log error_message column. Best-effort parse.
|
||
let errMsg = 'unknown_error';
|
||
try {
|
||
const parsed = JSON.parse(toolResult.content[0]?.text ?? '{}');
|
||
errMsg = parsed.error?.message ?? parsed.message ?? errMsg;
|
||
} catch { /* ignore */ }
|
||
try {
|
||
await executeRawJsonb(
|
||
engine,
|
||
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, error_message, params)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
||
[authInfo.clientId, agentName, name, latency, 'error', errMsg],
|
||
[logParamsObj],
|
||
);
|
||
} catch { /* best effort */ }
|
||
broadcastEvent({
|
||
agent: agentName,
|
||
operation: name,
|
||
params: broadcastParams,
|
||
scopes: authInfo.scopes.join(','),
|
||
latency_ms: latency,
|
||
status: 'error',
|
||
error: { code: 'op_error', message: errMsg },
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
return toolResult;
|
||
}
|
||
|
||
try {
|
||
await executeRawJsonb(
|
||
engine,
|
||
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params)
|
||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||
[authInfo.clientId, agentName, name, latency, 'success'],
|
||
[logParamsObj],
|
||
);
|
||
} catch { /* best effort */ }
|
||
broadcastEvent({
|
||
agent: agentName,
|
||
operation: name,
|
||
params: broadcastParams,
|
||
scopes: authInfo.scopes.join(','),
|
||
latency_ms: latency,
|
||
status: 'success',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
return toolResult;
|
||
});
|
||
|
||
// F14: wrap transport setup + handleRequest in try/catch. Without this,
|
||
// an SDK-level throw (e.g., schema parse failure on a malformed request)
|
||
// propagates to express's default error handler, which renders an HTML
|
||
// error page — clients expecting JSON-RPC envelopes break. On
|
||
// !res.headersSent we emit a minimal JSON 500 so the client at least
|
||
// gets parseable JSON back.
|
||
try {
|
||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined as any });
|
||
await server.connect(transport);
|
||
await transport.handleRequest(req, res, req.body);
|
||
} catch (e) {
|
||
console.error('MCP request handler error:', e instanceof Error ? e.message : e);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({
|
||
error: 'internal_error',
|
||
message: e instanceof Error ? e.message : 'Unknown error',
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// v0.38 ingestion substrate — POST /ingest (webhook source)
|
||
//
|
||
// The webhook ingestion source lives INSIDE serve --http (NOT in the
|
||
// ingestion daemon) per the /plan-eng-review E1 decision. This avoids
|
||
// cross-process IPC: the daemon supervises only daemon-side sources
|
||
// (file-watcher, inbox-folder, cron-scheduler) while serve --http hosts
|
||
// the network surface and submits Minion jobs directly.
|
||
//
|
||
// Auth: existing OAuth `write` scope. Rate limit: 100 events / 10s per
|
||
// IP (reuses the IP-keyed pattern from ccRateLimiter; a future tweak
|
||
// could key on authInfo.clientId for fairer per-agent fairness).
|
||
// Payload cap: 1 MB default. Content-type allowlist: markdown, plain,
|
||
// HTML, JSON. Binary content is REJECTED with HTTP 415 in v1 — the
|
||
// binary-upload flow ships as a separate route in a later wave when
|
||
// content-type processors land.
|
||
//
|
||
// Events always carry untrusted_payload: true because the input came
|
||
// over the network from an OAuth-authenticated but otherwise untrusted
|
||
// source (Zapier / IFTTT / Apple Shortcuts). The downstream
|
||
// ingest_capture handler logs the flag; a future v2 wave wires it
|
||
// through the put_page op to skip auto-link.
|
||
// ---------------------------------------------------------------------------
|
||
const ingestRateLimiter = rateLimit({
|
||
windowMs: 10_000, // 10 seconds
|
||
limit: 100, // 100 events per IP per window
|
||
standardHeaders: 'draft-7',
|
||
legacyHeaders: false,
|
||
message: { error: 'rate_limit_exceeded', message: 'too many /ingest events; backoff and retry' },
|
||
});
|
||
|
||
// Maximum payload bytes for POST /ingest. Configurable via env. Default 1 MB.
|
||
const ingestMaxBytes = (() => {
|
||
const fromEnv = process.env.GBRAIN_INGEST_MAX_BYTES;
|
||
if (!fromEnv) return 1_048_576;
|
||
const n = parseInt(fromEnv, 10);
|
||
return Number.isFinite(n) && n > 0 ? n : 1_048_576;
|
||
})();
|
||
|
||
// Content-type allowlist: text-shaped types only in v1. The handler
|
||
// routes binary content_types with HTTP 415; a future wave + skillpack
|
||
// processors will accept image/audio/video/pdf via a separate flow.
|
||
const INGEST_ALLOWED_CONTENT_TYPES: ReadonlySet<IngestionContentType> = new Set([
|
||
'text/markdown',
|
||
'text/plain',
|
||
'text/html',
|
||
'application/json',
|
||
]);
|
||
|
||
// Single MinionQueue instance shared across POST /ingest invocations
|
||
// (the queue is stateless beyond the engine handle; reusing avoids
|
||
// per-request construction).
|
||
const ingestQueue = new MinionQueue(engine);
|
||
|
||
app.post(
|
||
'/ingest',
|
||
ingestRateLimiter,
|
||
requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['write'] }),
|
||
express.raw({ type: '*/*', limit: ingestMaxBytes }),
|
||
async (req: Request, res: Response) => {
|
||
const startTime = Date.now();
|
||
const authInfo = (req as Request & { auth?: AuthInfo }).auth as AuthInfo;
|
||
const agentName = authInfo.clientName ?? authInfo.clientId;
|
||
|
||
// v0.39.3.0 BUG-2: outer try/catch ensures any unexpected throw
|
||
// returns a JSON envelope instead of leaking express's default HTML
|
||
// error page. Mirrors the MCP handler's F14 pattern (serve-http.ts
|
||
// F14 envelope around transport.handleRequest). The `!res.headersSent`
|
||
// guard (codex F#16) prevents a second-response attempt if the throw
|
||
// happens after the inner queue.add try/catch already responded.
|
||
try {
|
||
|
||
// v0.39.3.0 BUG-2: explicit null/undefined guard BEFORE body coercion.
|
||
// When the request has no body at all (no Content-Length header, no
|
||
// body-parser fed us anything), `req.body` is `undefined`. The pre-fix
|
||
// code's `else` branch called `Buffer.from(JSON.stringify(undefined),
|
||
// 'utf8')` — and `JSON.stringify(undefined) === undefined` (the
|
||
// literal, not the string), which makes `Buffer.from(undefined, 'utf8')`
|
||
// throw TypeError. Express's default error handler then served an HTML
|
||
// 500 page. Guard fires first to keep the response shape JSON.
|
||
if (req.body == null) {
|
||
res.status(400).json({
|
||
error: 'empty_body',
|
||
message: 'POST /ingest requires a non-empty body',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Express raw() returns a Buffer. Decode as UTF-8; reject non-UTF-8
|
||
// bytes loudly so callers know their payload was garbled.
|
||
let body: Buffer;
|
||
if (Buffer.isBuffer(req.body)) {
|
||
body = req.body;
|
||
} else if (typeof req.body === 'string') {
|
||
body = Buffer.from(req.body, 'utf8');
|
||
} else {
|
||
// express.json or urlencoded fired earlier in the chain and parsed
|
||
// for us. Re-serialize so we can hash and forward. The null/undefined
|
||
// case is already guarded above so JSON.stringify produces a real
|
||
// string here (objects round-trip, primitives become their JSON form).
|
||
body = Buffer.from(JSON.stringify(req.body), 'utf8');
|
||
}
|
||
|
||
if (body.length === 0) {
|
||
res.status(400).json({ error: 'empty_body', message: 'POST /ingest requires a non-empty body' });
|
||
return;
|
||
}
|
||
|
||
// Detect content_type. Caller can override via the X-Gbrain-Content-Type
|
||
// header for the JSON case (since the request's Content-Type would say
|
||
// application/json but the user might intend the body to be markdown).
|
||
const declared = (req.header('x-gbrain-content-type') || req.header('content-type') || '').toLowerCase();
|
||
let contentType: IngestionContentType;
|
||
if (declared.startsWith('text/markdown')) {
|
||
contentType = 'text/markdown';
|
||
} else if (declared.startsWith('text/html')) {
|
||
contentType = 'text/html';
|
||
} else if (declared.startsWith('text/plain')) {
|
||
contentType = 'text/plain';
|
||
} else if (declared.startsWith('application/json')) {
|
||
contentType = 'application/json';
|
||
} else if (declared.startsWith('text/')) {
|
||
// Unknown text/* sub-types pass through as text/plain.
|
||
contentType = 'text/plain';
|
||
} else {
|
||
// Binary or unknown — rejected in v1.
|
||
res.status(415).json({
|
||
error: 'unsupported_content_type',
|
||
message: `content_type '${declared}' not supported. Use one of: ${[...INGEST_ALLOWED_CONTENT_TYPES].join(', ')}. ` +
|
||
'Binary content (image/audio/video/pdf) is not yet supported via POST /ingest — install a content-type processor skillpack.',
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!INGEST_ALLOWED_CONTENT_TYPES.has(contentType)) {
|
||
res.status(415).json({
|
||
error: 'unsupported_content_type',
|
||
message: `content_type '${contentType}' is in the taxonomy but not currently accepted by POST /ingest`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const content = body.toString('utf8');
|
||
const contentHash = computeContentHash(content);
|
||
const sourceUri = (req.header('x-gbrain-source-uri') || `mcp-webhook:${authInfo.clientId}:${Date.now()}`).slice(0, 1024);
|
||
const sourceId = (req.header('x-gbrain-source-id') || `webhook-${authInfo.clientId}`).slice(0, 256);
|
||
const callerSlug = req.header('x-gbrain-slug');
|
||
|
||
const event: IngestionEvent = {
|
||
source_id: sourceId,
|
||
source_kind: 'webhook',
|
||
source_uri: sourceUri,
|
||
received_at: new Date().toISOString(),
|
||
content_type: contentType,
|
||
content,
|
||
content_hash: contentHash,
|
||
untrusted_payload: true, // ALWAYS true for network input
|
||
metadata: {
|
||
ip: req.ip,
|
||
user_agent: req.header('user-agent') ?? '',
|
||
client_id: authInfo.clientId,
|
||
...(callerSlug ? { slug: callerSlug } : {}),
|
||
},
|
||
};
|
||
|
||
const validationErr = validateIngestionEvent(event);
|
||
if (validationErr) {
|
||
res.status(400).json({
|
||
error: 'invalid_event',
|
||
message: validationErr.message,
|
||
field: validationErr.field,
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const job = await ingestQueue.add(
|
||
'ingest_capture',
|
||
{
|
||
event,
|
||
...(callerSlug ? { slug: callerSlug } : {}),
|
||
},
|
||
{
|
||
// Idempotency: same content from the same client within the
|
||
// queue's lifetime is a single job. Different content gets
|
||
// different jobs. Daemon-side dedup catches the 24h window;
|
||
// the queue-level idempotency catches simultaneous retries.
|
||
idempotency_key: `ingest:webhook:${authInfo.clientId}:${contentHash}`,
|
||
// Cap waiting jobs from a single client so a runaway integration
|
||
// can't fill the queue.
|
||
maxWaiting: 50,
|
||
},
|
||
);
|
||
|
||
const latency = Date.now() - startTime;
|
||
try {
|
||
await executeRawJsonb(
|
||
engine,
|
||
`INSERT INTO mcp_request_log (token_name, agent_name, operation, latency_ms, status, params)
|
||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||
[authInfo.clientId, agentName, 'webhook_ingest', latency, 'success'],
|
||
[{ content_type: contentType, content_hash: contentHash, bytes: body.length, job_id: job.id }],
|
||
);
|
||
} catch { /* best effort */ }
|
||
broadcastEvent({
|
||
agent: agentName,
|
||
operation: 'webhook_ingest',
|
||
scopes: authInfo.scopes.join(','),
|
||
latency_ms: latency,
|
||
status: 'success',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
res.status(202).json({
|
||
job_id: job.id,
|
||
content_hash: contentHash,
|
||
source_id: sourceId,
|
||
message: 'Accepted. Event queued for ingestion.',
|
||
});
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error('POST /ingest queue submission error:', msg);
|
||
res.status(500).json({
|
||
error: 'queue_submission_failed',
|
||
message: msg,
|
||
});
|
||
}
|
||
|
||
// v0.39.3.0 BUG-2: outer try/catch close — anything that throws BEFORE
|
||
// the inner queue.add try/catch lands here. The headersSent guard
|
||
// (codex F#16) skips the second-response attempt if the inner block
|
||
// already wrote a response and then threw on a downstream line (e.g.
|
||
// a logging side-effect after `res.status(202).json(...)`).
|
||
} catch (outerErr) {
|
||
const msg = outerErr instanceof Error ? outerErr.message : String(outerErr);
|
||
console.error('POST /ingest unexpected handler error:', msg);
|
||
if (!res.headersSent) {
|
||
res.status(500).json({
|
||
error: 'internal_error',
|
||
message: msg,
|
||
});
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// POST /webhooks/github — push-triggered sync (v0.40 Federated Sync v2)
|
||
// ---------------------------------------------------------------------------
|
||
// Anonymous endpoint by necessity (GitHub doesn't carry an OAuth token).
|
||
// Auth is via per-source HMAC-SHA256 in the X-Hub-Signature-256 header.
|
||
//
|
||
// D3: 60 req/min/IP rate limit + pre-DB short-circuit on missing
|
||
// signature, so probe traffic doesn't even touch the source-lookup
|
||
// query.
|
||
// D5: event=push AND ref-match against sources.config.tracked_branch.
|
||
// Other event types (ping, pull_request, etc.) return 202 'ignored'
|
||
// so GitHub doesn't retry.
|
||
// D15.5: HMAC compare uses the shared safeHexEqual helper.
|
||
// D18: submits 'sync' job with auto_embed_backfill=true and priority -10
|
||
// (above autopilot's 0).
|
||
// ---------------------------------------------------------------------------
|
||
const githubWebhookLimiter = rateLimit({
|
||
windowMs: 60_000,
|
||
limit: 60,
|
||
standardHeaders: 'draft-7',
|
||
legacyHeaders: false,
|
||
message: { error: 'rate_limit_exceeded', message: 'too many GitHub webhook requests' },
|
||
});
|
||
|
||
app.post(
|
||
'/webhooks/github',
|
||
githubWebhookLimiter,
|
||
express.raw({ type: '*/*', limit: '1mb' }),
|
||
async (req: Request, res: Response) => {
|
||
// D3 pre-DB short-circuit: missing signature → 401 without any
|
||
// source lookup. Bot probe traffic ends here.
|
||
const sigHeader = req.header('X-Hub-Signature-256');
|
||
if (!sigHeader) {
|
||
res.status(401).json({ error: 'missing_signature', message: 'X-Hub-Signature-256 header is required' });
|
||
return;
|
||
}
|
||
|
||
// D5: filter by event header. GitHub fires webhooks for every event
|
||
// type. Anything other than 'push' is acknowledged with 202 + reason
|
||
// so GitHub doesn't retry — but no source lookup or job submission.
|
||
const event = req.header('X-GitHub-Event') ?? '';
|
||
if (event !== 'push') {
|
||
res.status(202).json({ status: 'ignored', reason: `event=${event || '(missing)'}` });
|
||
return;
|
||
}
|
||
|
||
const payload = Buffer.isBuffer(req.body) ? req.body : Buffer.from(JSON.stringify(req.body), 'utf8');
|
||
if (payload.length === 0) {
|
||
res.status(400).json({ error: 'empty_body' });
|
||
return;
|
||
}
|
||
|
||
let parsed: { repository?: { full_name?: string }; ref?: string };
|
||
try {
|
||
parsed = JSON.parse(payload.toString('utf8'));
|
||
} catch {
|
||
res.status(400).json({ error: 'malformed_json' });
|
||
return;
|
||
}
|
||
|
||
const fullName = parsed.repository?.full_name;
|
||
const ref = parsed.ref;
|
||
if (!fullName || !ref) {
|
||
res.status(400).json({ error: 'missing_fields', message: 'repository.full_name and ref are required' });
|
||
return;
|
||
}
|
||
|
||
// Source lookup via the v87 partial expression index on
|
||
// config->>'github_repo'. fast even on large brains.
|
||
let source: { id: string; config: Record<string, unknown> | string } | null = null;
|
||
try {
|
||
const rows = await engine.executeRaw<{ id: string; config: Record<string, unknown> | string }>(
|
||
`SELECT id, config FROM sources WHERE config->>'github_repo' = $1 LIMIT 1`,
|
||
[fullName],
|
||
);
|
||
source = rows[0] ?? null;
|
||
} catch (err) {
|
||
console.error('webhook: source lookup error:', err);
|
||
res.status(500).json({ error: 'lookup_failed' });
|
||
return;
|
||
}
|
||
if (!source) {
|
||
res.status(404).json({ error: 'unknown_repo', repo: fullName });
|
||
return;
|
||
}
|
||
|
||
const cfg = (typeof source.config === 'string' ? JSON.parse(source.config) : source.config) as {
|
||
webhook_secret?: string;
|
||
tracked_branch?: string;
|
||
};
|
||
|
||
// D5: ref must match the configured tracked branch (default 'main').
|
||
const trackedBranch = cfg.tracked_branch ?? 'main';
|
||
const expectedRef = `refs/heads/${trackedBranch}`;
|
||
if (ref !== expectedRef) {
|
||
res.status(202).json({
|
||
status: 'ignored',
|
||
reason: `ref_mismatch`,
|
||
received_ref: ref,
|
||
tracked_branch: trackedBranch,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const secret = cfg.webhook_secret;
|
||
if (!secret || typeof secret !== 'string') {
|
||
res.status(401).json({ error: 'webhook_not_configured', message: 'Run: gbrain sources webhook set ' + source.id });
|
||
return;
|
||
}
|
||
|
||
// HMAC verify. GitHub sends "sha256=<hex>" — strip the prefix BEFORE
|
||
// safeHexEqual because Buffer.from('sha256=...', 'hex') silently
|
||
// truncates at the first non-hex char (the 's'), leaving both
|
||
// operands as 0-byte buffers and making every signature "match".
|
||
// Pinned by test/sources-webhook.test.ts tamper assertions.
|
||
const { createHmac } = await import('node:crypto');
|
||
const computedHex = createHmac('sha256', secret).update(payload).digest('hex');
|
||
const prefix = 'sha256=';
|
||
if (!sigHeader.startsWith(prefix)) {
|
||
res.status(401).json({ error: 'signature_mismatch', message: 'expected sha256= prefix' });
|
||
return;
|
||
}
|
||
if (!safeHexEqual(sigHeader.slice(prefix.length), computedHex)) {
|
||
res.status(401).json({ error: 'signature_mismatch' });
|
||
return;
|
||
}
|
||
|
||
// Submit sync job with priority -10 (above autopilot's 0).
|
||
try {
|
||
const queue = new MinionQueue(engine);
|
||
const job = await queue.add(
|
||
'sync',
|
||
{
|
||
sourceId: source.id,
|
||
auto_embed_backfill: true,
|
||
embed_reason: 'webhook',
|
||
},
|
||
{
|
||
priority: -10,
|
||
idempotency_key: `webhook:sync:${source.id}:${Math.floor(Date.now() / 30_000)}`,
|
||
maxWaiting: 1,
|
||
},
|
||
);
|
||
res.status(202).json({ job_id: job.id, source_id: source.id });
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
console.error('webhook: queue submission error:', msg);
|
||
res.status(500).json({ error: 'queue_submission_failed', message: msg });
|
||
}
|
||
},
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Start server
|
||
// ---------------------------------------------------------------------------
|
||
const clientCount = await sql`SELECT count(*)::int as count FROM oauth_clients`;
|
||
|
||
app.listen(port, bind, () => {
|
||
console.error(`
|
||
╔══════════════════════════════════════════════════════╗
|
||
║ GBrain MCP Server v${VERSION.padEnd(37)}║
|
||
╠══════════════════════════════════════════════════════╣
|
||
║ Port: ${String(port).padEnd(40)}║
|
||
║ Bind: ${bind.padEnd(40)}║
|
||
║ Engine: ${(config.engine || 'pglite').padEnd(40)}║
|
||
║ Issuer: ${issuerUrl.origin.padEnd(40)}║
|
||
║ Clients: ${String((clientCount[0] as any).count).padEnd(40)}║
|
||
║ DCR: ${(enableDcr ? (enableDcrInsecure ? 'enabled (INSECURE: client_credentials)' : 'enabled') : 'disabled').padEnd(40)}║
|
||
║ Skills: ${skillStatus.bannerValue.padEnd(40)}║
|
||
║ Token TTL: ${(tokenTtl + 's').padEnd(40)}║
|
||
╠══════════════════════════════════════════════════════╣
|
||
║ Admin: http://localhost:${port}/admin${' '.repeat(Math.max(0, 19 - String(port).length))}║
|
||
║ MCP: http://localhost:${port}/mcp${' '.repeat(Math.max(0, 21 - String(port).length))}║
|
||
║ Health: http://localhost:${port}/health${' '.repeat(Math.max(0, 18 - String(port).length))}║
|
||
╠══════════════════════════════════════════════════════╣
|
||
${bootstrapFromEnv
|
||
? '║ Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN ║\n╚══════════════════════════════════════════════════════╝'
|
||
: suppressBootstrapPrint
|
||
? '║ Admin Token: hidden (non-TTY log-leak guard) ║\n║ set $GBRAIN_ADMIN_BOOTSTRAP_TOKEN, or pass ║\n║ --print-admin-token on a trusted terminal. ║\n╚══════════════════════════════════════════════════════╝'
|
||
: `║ Admin Token (paste into /admin login): ║\n║ ${bootstrapToken.substring(0, 50)} ║\n║ ${bootstrapToken.substring(50).padEnd(50)} ║\n╚══════════════════════════════════════════════════════╝`}
|
||
`);
|
||
});
|
||
}
|