mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 20:50:34 +00:00
v0.26.0 feat: GBrain — MCP Keys OAuth 2.1 + HTTP server + admin dashboard (#358)
* feat: OAuth 2.1 schema tables + shared token utilities
Add oauth_clients, oauth_tokens, oauth_codes tables to both PGLite and
Postgres schemas. Migration v5 creates tables for existing databases.
PGLite now includes auth infrastructure (access_tokens, mcp_request_log,
OAuth tables) because `serve --http` makes it network-accessible.
Extract hashToken() and generateToken() to src/core/utils.ts for DRY
reuse across auth.ts and oauth-provider.ts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: GBrainOAuthProvider — MCP SDK OAuthServerProvider implementation
Implements OAuthServerProvider backed by raw SQL (PGLite or Postgres).
Supports client credentials, authorization code with PKCE, token refresh
with rotation, revocation, and legacy access_tokens fallback.
Key decisions from eng review:
- Uses raw SQL connection, not BrainEngine (OAuth is infrastructure)
- All tokens/secrets SHA-256 hashed before storage
- Legacy tokens grandfathered as read+write+admin
- sweepExpiredTokens() wrapped in try/catch (non-blocking startup)
- Client credentials: no refresh token per RFC 6749 4.4.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: scope + localOnly annotations on all 30 operations
Add AuthInfo, scope ('read'|'write'|'admin'), and localOnly fields to
Operation interface. Per-operation audit:
- 14 read ops, 9 write ops, 2 admin ops, 4 admin+localOnly ops
- sync_brain, file_upload, file_list, file_url: admin + localOnly
- Scope enforcement happens in serve-http.ts before handler dispatch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: HTTP MCP server with OAuth 2.1 + 27 OAuth tests
gbrain serve --http starts Express 5 server with:
- MCP SDK mcpAuthRouter (authorize, token, register, revoke endpoints)
- Custom client_credentials handler (SDK doesn't support CC grant)
- Bearer auth + scope enforcement on /mcp tool calls
- Admin dashboard auth via HTTP-only cookie + bootstrap token
- SSE live activity feed at /admin/events
- DCR default OFF (--enable-dcr to enable)
- Rate limiting on /token (50/15min)
- localOnly operations excluded from HTTP
CLI: gbrain serve --http [--port 3131] [--token-ttl 3600] [--enable-dcr]
Dependencies: express@5.2.1, express-rate-limit@7.5.1, cors@2.8.6
SDK pinned to exact 1.29.0 (was ^1.0.0)
27 new tests covering OAuth provider, scope enforcement, auth code flow,
refresh rotation, token revocation, legacy fallback, and sweep.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: React admin dashboard — 7 screens, dark theme, Krug-designed
Admin SPA at /admin with client-side routing (#login, #dashboard,
#agents, #log). Built with Vite + React, served from admin/dist/.
Screens:
- Login: one field, one button, zero happy talk
- Dashboard: metrics bar, SSE live activity feed, token health panel
- Agents: table with scopes/badges, + Register Agent button
- Register: modal form (name, scopes), 3 mindless choices
- Credentials: full-screen modal, copy buttons, download JSON, warning
- Request Log: paginated table (50/page), time-relative timestamps
- Agent Detail: slide-out drawer, config export tabs (Perplexity/Claude/JSON)
Design tokens: #0a0a0f bg, Inter + JetBrains Mono, 4-32px spacing.
Build: bun run build:admin (Vite, 65KB gzipped).
Admin API: /admin/api/register-client endpoint for dashboard registration.
SPA serving: Express static + index.html fallback for client-side routing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: add admin SPA lockfile
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.0.0.0)
Milestone release: multi-agent GBrain with OAuth 2.1, HTTP server,
and React admin dashboard. See CHANGELOG.md for details.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update project documentation for v1.0.0.0
Sync README, CLAUDE.md, and docs/mcp/ with the OAuth 2.1 + HTTP server
+ admin dashboard surface that shipped in v1.0.0.0.
- README.md: new "Remote MCP with OAuth 2.1" section covering
gbrain serve --http, admin dashboard, scoped operations, legacy
bearer fallback; add serve --http + auth notes to the commands
reference.
- CLAUDE.md: add src/commands/serve-http.ts, src/core/oauth-provider.ts,
admin/ directory as key files; document scope + localOnly additions
to Operation contract; add oauth.test.ts (27 cases) to the test list;
add v1.0.0 key-commands section clarifying that OAuth client
registration is via the /admin dashboard or SDK (no CLI subcommand).
- docs/mcp/DEPLOY.md: promote --http as the recommended remote path,
add OAuth 2.1 Setup section, list ChatGPT in supported clients,
remove the "not yet implemented" footer.
- docs/mcp/CHATGPT.md (new): unblocks the P0 TODO. Full ChatGPT
connector setup via OAuth 2.1 + PKCE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: wire gbrain auth subcommand with OAuth register-client
Previously auth.ts was a standalone script invoked via
`bun run src/commands/auth.ts`. CHANGELOG and README documented
`gbrain auth ...` commands that didn't actually work.
- Export `runAuth(args)` from auth.ts (keeps standalone entry intact
via `import.meta.url === file://${process.argv[1]}` check)
- Add `auth` to CLI_ONLY + dispatch in handleCliOnly
- New subcommand `gbrain auth register-client <name> [--grant-types]
[--scopes]` wraps GBrainOAuthProvider.registerClientManual
- Lazy DB check: only subcommands that need DATABASE_URL error out
Now the documented CLI flow works end to end:
gbrain auth register-client perplexity --grant-types client_credentials --scopes "read write"
gbrain serve --http --port 3131
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: reflect wired gbrain auth register-client CLI
After /ship, the doc subagent wrote docs assuming `gbrain auth
register-client` did not exist (it said so explicitly in CLAUDE.md:184).
A follow-up commit (c4a86ce) wired it into src/cli.ts + src/commands/auth.ts.
These docs were now contradicting reality.
- CLAUDE.md: removed "There is no gbrain auth register-client CLI
subcommand" claim, documented the three registration paths
(CLI / dashboard / SDK).
- README.md: replaced `bun run src/commands/auth.ts` hint with
`gbrain auth create|list|revoke|test` and `gbrain auth register-client`.
- docs/mcp/DEPLOY.md: added CLI registration example above the
programmatic example.
- TODOS.md: moved "ChatGPT MCP support (OAuth 2.1)" P0 item to
Completed with v1.0.0.0 completion note. Closes the P0 that had been
blocking the "every AI client" promise since v0.6.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: enable RLS on OAuth tables + loosen v24-exact test assertion
CI Tier 1 (Mechanical) was failing on 4 E2E tests after the v0.18.1 RLS
hardening landed on master (PR #343). Our v25 oauth_infrastructure migration
adds 3 new public tables (oauth_clients, oauth_tokens, oauth_codes) but
didn't enable RLS, so gbrain doctor's new check flagged them and the
"RLS on every public table" assertion failed.
Fixes:
- src/schema.sql: ALTER TABLE ... ENABLE ROW LEVEL SECURITY for the 3 OAuth
tables inside the existing BYPASSRLS-gated DO block (fresh installs).
- src/core/migrate.ts v25: append a BYPASSRLS-gated DO block after the OAuth
CREATE TABLE statements (existing installs on upgrade). Mirrors the v24
rls_backfill gating pattern — RAISE WARNING if the current role lacks
BYPASSRLS, so migrations don't silently lock the operator out.
- src/core/schema-embedded.ts: regenerated via `bun run build:schema`.
- test/e2e/mechanical.test.ts: one unrelated v24 test asserted the post-
migration version equals exactly '24'. That breaks when any later
migration exists (like our v25). Relaxed to `>= 24` since the test's
intent is "v24 didn't abort the chain", not "v24 is the final version".
Verified locally: 78/78 E2E tests pass against real Postgres 16 + pgvector.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: regenerate llms-full.txt for v1.0.0 docs
CI test/build-llms.test.ts > committed llms.txt + llms-full.txt match
current generator output failed. The committed llms-full.txt was built
before the v1.0.0 doc updates landed (OAuth 2.1 README section, new
docs/mcp/CHATGPT.md, CLAUDE.md serve-http references, etc.), so the
regen-drift guard flagged it.
Ran `bun run build:llms`. llms.txt is unchanged (skinny index still
matches); llms-full.txt picks up 166 net-new lines of bundled content.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* connected-gbrains PR 0 — minimal runtime (mounts, registry, aggregated RESOLVER) (#372)
* feat(mounts): connected-gbrains PR 0 foundation — registry + resolver + CLI
Lays the foundation for connected gbrains (v0.19.0) per the approved plan.
This is PR 0 — minimal runtime for direct-transport, path-mounted brains.
What this slice ships:
- src/core/brain-registry.ts — keyed BrainRegistry with lazy engine init,
schema-validated mounts.json loader, DuplicateMountPathError (load-bearing
identity check per Codex finding #9 correction), UnknownBrainError with
actionable available-id list. Pure: no AsyncLocalStorage, no singleton
mutation. ~280 LOC.
- src/core/brain-resolver.ts — 6-tier brain-id resolution mirroring
v0.18.0's source-resolver.ts so agents learn ONE mental model:
1. --brain <id> 2. GBRAIN_BRAIN_ID env 3. .gbrain-mount dotfile
4. longest-path match over registered mounts 5. (reserved v2 default)
6. 'host' fallback
Orthogonal to --source: --brain picks which DB, --source picks the repo
within that DB. Corruption-resistant: mounts.json load failures fall
through to 'host' instead of breaking every CLI invocation.
- src/commands/mounts.ts — `gbrain mounts add|list|remove` (direct transport
only). Validates on add (path exists on disk, id regex, no dupes). WARNS
but does not block on same db_url/db_path across ids (teams may
legitimately alias a remote brain). Password redaction in list output.
Atomic write via temp+rename. 0600 perms. PR 1 adds pin/sync/enable;
PR 2 adds --mcp-url + OAuth.
- src/cli.ts — wires `gbrain mounts` into handleCliOnly (no DB required
for the config-only subcommands).
- test/brain-registry.test.ts (28 cases): schema validation across every
malformed-input branch, ALS-free resolution, duplicate id + path detection,
disabled-mount exclusion, UnknownBrainError context.
- test/brain-resolver.test.ts (22 cases): priority order (explicit > env >
dotfile > path-prefix > fallback), dotfile walk-up, malformed dotfile
recovery, longest-prefix match, sibling-path false-positive guard,
loader-failure defense.
- test/mounts-cli.test.ts (17 cases): parseAddArgs surface, redactUrl,
atomic write, add/list/remove roundtrip via temp HOME.
67 new tests, all green. Typecheck clean. Depends on mcp-key-mgmt (base
branch) for the OAuth/scope annotations that PR 2 will leverage.
Next in this branch: PR 0 still needs (a) the deep host-brain-bias audit
(postgres-engine internal singleton fallback + a few operations.ts
callers), (b) OperationContext threading to make ctx.brainId populated at
dispatch, (c) composeResolvers + composeManifests, (d) aggregated
~/.gbrain/mounts-cache/ for host-agent runtime ownership.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(mounts): brains-and-sources mental model + agent routing convention
Two orthogonal axes organize GBrain knowledge. Users AND agents need to
understand both, or queries misroute silently.
--brain → WHICH DATABASE (host + mounts)
--source → WHICH REPO IN DB (v0.18.0 sources: wiki, gstack, ...)
Both axes use the same 6-tier resolution (explicit > env > dotfile >
path-prefix > default > fallback), so learning one teaches both.
Ships:
- docs/architecture/brains-and-sources.md — canonical mental model doc.
Covers four topologies with ASCII diagrams:
1. Single-person developer (one brain, one source)
2. Personal brain with multiple repos (one brain, N sources)
3. Personal + one team brain mount (2 brains)
4. Senior user with multiple team memberships (N mounted team brains
alongside personal) — the CEO-class topology
Explicit "when to move each axis" decision table. Generic example names
throughout per the project's privacy rule.
- skills/conventions/brain-routing.md — agent-facing decision table.
Rules for when to switch brain (team-owned question, explicit name,
data owner changes) vs switch source (working in a repo, topic scoped
to one repo). Cross-brain federation is latent-space only in v0.19 —
the agent fans out; the DB never does. Anti-patterns listed: silent
brain jumps, writing to host when data is team-owned, missing brain
prefix in citations, ignoring .gbrain-mount dotfiles.
- CLAUDE.md — adds "Two organizational axes (read this first)" section
at the top pointing at both new docs.
- AGENTS.md — adds brains-and-sources.md + brain-routing.md to the
"read this order" (positions 3 and 4, before RESOLVER.md).
- skills/RESOLVER.md — adds brain-routing.md to the Conventions section
so it appears alongside quality.md, brain-first.md, subagent-routing.md.
No code changes. Pre-existing check-resolvable warnings unchanged (2
warnings on base unrelated to this work). 67 PR-0 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(mounts): thread brainId through OperationContext + subagent chain
PR 0 plumbing for connected gbrains. Adds an optional brainId field that
identifies which database an operation targets and ensures subagents
inherit the parent job's brain instead of process-wide defaults. No
dispatch-path changes in this commit — that is PR 1 (registry wiring at
MCP + CLI entry points). The fields exist so callers can set them now
and downstream code respects them.
Changes:
- src/core/operations.ts: OperationContext grows `brainId?: string`.
Optional for back-compat. 'host' is the implicit default when absent.
Orthogonal to v0.18.0's source_id (source = which repo within the
brain, brain = which database). See docs/architecture/brains-and-sources.md.
- src/core/minions/types.ts: SubagentHandlerData gains `brain_id?: string`.
Parent jobs set this when submitting a child subagent to lock the
child into a specific brain. Omitted = host (unchanged behavior).
- src/core/minions/handlers/subagent.ts: buildBrainTools call site
reads data.brain_id and passes it through. Child subagents spawned
from this handler will see the same brainId unless they override in
their own data.
- src/core/minions/tools/brain-allowlist.ts: BuildBrainToolsOpts +
OpContextDeps grow brainId; buildOpContext stamps it on every
OperationContext the subagent builds for tool calls. Addresses Codex
finding #6 (brain-allowlist hardwired parent config without brain
awareness, so switching brain only in subagent.ts was not enough).
Tests: 166 affected tests green (subagent suite + minions + brain
registry + resolver). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(mounts): composeResolvers + composeManifests + aggregated cache
The runtime ownership seam for connected gbrains (Codex finding #3 from
plan review): check-resolvable.ts VALIDATES RESOLVER.md; it does not
DISPATCH skills. Host agents (Wintermute/OpenClaw/Claude Code) read
skills/RESOLVER.md directly to route user requests. Without an aggregated
resolver, mounted team brains cannot contribute skills to the host
agent's routing table.
This commit adds the aggregation:
- src/core/mounts-cache.ts (NEW): pure composeResolvers + composeManifests
functions plus filesystem writers for ~/.gbrain/mounts-cache/. The
aggregated files carry every host skill plus every mount skill,
namespace-prefixed (e.g. `yc-media::ingest`). Host skills always beat
a same-named mount skill (locked decision 1); bare-name collisions
between two mounts surface as structured ambiguity info so doctor can
warn (PR 1).
Also addresses Codex finding #8: manifests compose alongside the
resolver, else doctor conformance breaks on remote skills.
- src/commands/mounts.ts: refreshMountsCache() called on `mounts add`
and `mounts remove` (the latter clearing the cache entirely when the
last mount goes away). Uses findRepoRoot() to locate the host skills
dir; skips with a stderr note when run outside a gbrain repo so the
user isn't confused by a "cache not refreshed" error in the wrong
cwd.
- test/mounts-cache.test.ts (NEW): 23 unit tests covering empty world,
host-only, single mount, two-mount ambiguity, host-shadows-mount,
disabled mount excluded, missing RESOLVER.md is a no-op, manifest
composition with same-name collision, render shape, atomic rewrite,
clear on missing dir.
Output format for ~/.gbrain/mounts-cache/RESOLVER.md adds a Brain column
so host agents can see which brain each trigger routes to at a glance,
plus Shadows and Ambiguous sections when those conditions exist.
Tests: 90 PR 0 tests green (brain-registry + resolver + mounts-cache +
mounts-cli). Full suite regression pending in task 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(mounts): force instance-level pool for mount brains + CI guard
Closes the silent-singleton-share bug Codex flagged as finding #1 from
the plan review: two direct-transport mounts with different Postgres
URLs would both fall through postgres-engine.ts's `get sql()` getter to
db.getConnection() and quietly share whichever singleton connected
first. Your yc-media writes end up in garrys-list or vice versa. No
error at the call site — just wrong data.
The fix:
- src/core/brain-registry.ts: initMountBrain now passes poolSize when
calling engine.connect(). That forces postgres-engine.ts:33-60 down
the instance-level path (setting this._sql) instead of the module
singleton path (calling db.connect). Hard-coded 5 for PR 0 — per-mount
override is PR 1. PGLite ignores poolSize (no pool concept), so this
is Postgres-specific.
Host brain still uses the singleton path via initHostBrain (unchanged).
That is fine for PR 0: the singleton is "the host's one connection"
by definition. PR 1 removes the singleton entirely once every CLI
command is engine-injectable.
- scripts/check-no-legacy-getconnection.sh (NEW): CI grep guard against
new db.getConnection() / db.connect() calls landing in src/core/ or
src/commands/ (the multi-brain dispatch surface). Has an explicit
ALLOWED list grandfathering today's legitimate callers, each marked
"PR 1 refactors" so the list shrinks over time. Skips comment lines
so the grep doesn't trip on doc references to the old pattern.
- package.json: scripts.test chains the new guard after the existing
check-jsonb-pattern + check-progress-to-stdout guards. `bun run test`
now fails the build on singleton regression.
Tests: 295 affected pass (registry, resolver, mounts-cache, mounts-cli,
minions, pglite-engine). Typecheck clean. CI guard reports "ok: no new
singleton callers" on current tree.
Left for PR 1: remove the singleton fallback in postgres-engine.ts's
`get sql()` entirely; refactor src/commands/doctor.ts, files.ts,
repair-jsonb.ts, serve-http.ts, init.ts, and the 3 localOnly ops in
operations.ts (file_list, file_upload, file_url) to accept ctx.engine
explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mounts): codex review findings — namespace survives shadow + atomic tmp names + honest PR 0 docstrings
Codex outside-voice review on PR #372 found 5 issues. Real bugs fixed, overclaims
rewritten. Details:
P2 (real bug): composeResolvers and composeManifests were silently dropping
mount entries when a host skill shared the short name, which made the
namespace-qualified form `<mount>::<skill>` unreachable once host defined
the same short name. That defeated the entire namespace-disambiguation
model — if host had `ingest`, no mount could ship an `ingest` skill even
with explicit `yc-media::ingest`. Fix: always keep namespace-qualified
mount entries in the composed output. Shadow tracking moves to metadata
(`shadows[]`) that doctor can warn on, but never drops routing.
Before: host ingest + yc-media ingest → only 1 entry (host), yc-media::ingest unreachable
After: host ingest + yc-media ingest → 2 entries: bare `ingest` = host, `yc-media::ingest` = mount
Verified live: gbrain mounts add of a mount with `ingest` now shows
`team-demo::ingest` alongside host `ingest` in the aggregated manifest.
P1 (real bug): writeMountsFile + writeMountsCache used fixed `.tmp`
filenames. Two concurrent `gbrain mounts add` invocations (e.g. from
parallel terminals or CI) would clobber each other's temp file and
one writer's update would be lost. Fix: tmp filenames include
`process.pid + random suffix` so every writer has its own scratch file.
The atomic rename is self-contained per-writer. (Full lock + read-modify-
write safety deferred to PR 1 under `gbrain mounts sync --lock`.)
P1 (honesty): `SubagentHandlerData.brain_id` +
`BuildBrainToolsOpts.brainId` docstrings claimed child jobs inherit the
parent's brain and brain tools target the resolved brain. True for the
`ctx.brainId` field only — `ctx.engine` is still the worker's base
engine at dispatch time because `buildOpContext` doesn't yet do the
registry lookup, and `gbrain agent run` doesn't yet accept `--brain` to
populate the field on submission. Rewrote both docstrings to state the
PR 0 behavior explicitly (field plumbed, engine routing is PR 1) so
nobody reads the code thinking multi-brain subagents already work.
Also cleaned up two `require('fs')` runtime imports left over from the
initial PR — swapped for ESM named imports (renameSync). Pre-existing
style issue surfaced by the self-review pass.
Tests: 90 PR-0 tests pass. Updated two shadow-related test cases to
assert the corrected semantics (both entries survive, host wins bare
name, namespace form routes to mount).
Not fixed in this commit (documented as known PR 0 limitations):
- `file_list` / `file_upload` / `file_url` in operations.ts still hit the
singleton (localOnly + admin, never reachable from HTTP MCP — safe in
practice, refactor in PR 1 alongside command-level cleanups).
- writeMountsCache's two-file swap (RESOLVER.md + manifest.json) is not
atomic across files; readers can briefly observe mismatched pairs.
Acceptable because the cache is recomputable at any time from
mounts.json. Generation-directory swap is PR 1 work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tests): bump hook timeouts for 21-migration PGLite init under full-suite load
Root cause of 19 pre-existing full-suite flakes (CHANGELOG v0.18.0 noted
"17 pre-existing master timeouts"): every PGLite test does
beforeAll/beforeEach(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema(); // runs 21 migrations through v0.18.2
});
In isolation this takes ~5s. Under full-suite contention (128 files,
process-shared FS and CPU) it exceeds bun's default 5000ms hook timeout,
beforeEach times out, engine stays undefined, then afterEach crashes
with `TypeError: undefined is not an object (evaluating 'engine.disconnect')`.
That single hook failure reports as the whole test "failing" even though
the test body never executed, which is why the failure count sometimes
looked inflated compared to the number of genuinely-broken tests.
Fix applied across 7 test files:
- Raise setup hook timeout to 30_000 (6x the default) — gives migration
init enough headroom even under worst-case load without masking real
regressions in a post-migration test.
- Raise teardown hook timeout to 15_000 — engine.disconnect() is usually
fast but can stall when PGLite's WASM runtime is still completing a
migration at shutdown.
- Add `if (engine) await engine.disconnect()` guard so afterEach doesn't
double-fault when beforeEach already failed. This was the source of
the opaque "(unnamed)" failures — they were disconnect crashes,
not test-body failures.
Files:
test/dream.test.ts (5 beforeEach + 5 afterEach blocks)
test/orphans.test.ts (1 pair)
test/brain-allowlist.test.ts (1 pair)
test/oauth.test.ts (1 pair)
test/extract-db.test.ts (1 pair)
test/multi-source-integration.test.ts (1 pair)
test/core/cycle.test.ts (1 pair)
Results on the merged PR 0 branch:
Before: 2175 pass / 20 fail / 3 errors
After: 2281 pass / 0 fail / 0 errors (+106 tests running that
were previously blocked
by the timed-out hooks)
No changes to production code. No test assertions changed. Just
timeout-bump + null-guard discipline that should have been in these
hooks from the start. The real longer-term fix is reusing an engine
across tests where possible (brain-allowlist.test.ts already does this
via beforeAll+DELETE-pages pattern), but that's per-file structural
work — out of scope for this cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: regenerate llms-full.txt for brains-and-sources + brain-routing docs
The test/build-llms.test.ts test validates that the committed llms.txt
and llms-full.txt match the current generator output. PR 0 added
docs/architecture/brains-and-sources.md content paths and updated
CLAUDE.md + skills/RESOLVER.md in earlier commits, but the generated
bundle file wasn't regenerated alongside. This caused one of the 20
fails we chased down today — a straight content mismatch, not a runtime
bug. Running `bun run build:llms` picks up the new section content so
the bundle matches the sources again.
No functional change. Only the compiled doc bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Bump version 1.0.0.0 → 0.22.0
OAuth + admin dashboard is meaningful but doesn't quite warrant the
major-version reset to 1.0. Renumber as v0.22.0, slotting cleanly above
master's v0.21.0 (Cathedral II).
Touched:
- VERSION, package.json: 1.0.0.0 → 0.22.0
- CHANGELOG.md: heading + "BEFORE/AFTER v1.0" table + "To take advantage"
+ "pre-v1.0" all renamed. Narrative voice unchanged otherwise.
- TODOS.md: ChatGPT MCP completion stamp updated to v0.22.0 (2026-04-25).
- CLAUDE.md, README.md, docs/mcp/{DEPLOY,CHATGPT}.md, src/schema.sql,
src/core/schema-embedded.ts: every reader-facing v1.0.0 reference
rewritten to v0.22.0 / pre-v0.22 in the same place.
- llms-full.txt: regenerated to match.
Slug-test occurrences of "v1.0.0" (`test/slug-validation.test.ts`,
`test/file-upload-security.test.ts`) and the `HOMEBREW_FOR_PERSONAL_AI`
roadmap reference to a future v1.0 vision left intact — those are
unrelated to this branch's release version.
Typecheck clean. cli + oauth + slug + file-upload tests pass (106 tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.26.0 fix: 4 security findings from /cso pass + version bump
Bumped 0.22.0 → 0.26.0 to slot above master's v0.21 chain with headroom
for v0.23/0.24/0.25 to ship from master between now and merge.
Security fixes (all from CSO finding writeups):
#1 cookie-parser middleware — admin dashboard auth was silently broken.
Express 5 has no built-in cookie parsing; req.cookies was always
undefined, so /admin/login set the cookie but every subsequent admin
API call returned 401. Added cookie-parser@^1.4.7 + @types/cookie-parser
as direct + dev deps. app.use(cookieParser()) wired before CORS.
#2 + #3 TOCTOU races — exchangeAuthorizationCode and exchangeRefreshToken
used SELECT-then-DELETE, letting concurrent requests with the same
code/refresh both pass the SELECT before either ran DELETE, both
issuing token pairs. Switched to atomic DELETE...RETURNING. RFC 6749
§10.5 (codes) + §10.4 (refresh detection) violations closed. Added
regression tests that fire 10 concurrent exchanges and assert exactly
one wins — both pass.
#5 pgArray escape + DCR redirect_uri validation — pgArray() did
`arr.join(',')` with no escaping, so an element containing a comma
would be parsed by Postgres as TWO array elements. With --enable-dcr
on, this could smuggle a second redirect_uri into a registered client
and steal auth codes. Now every element is double-quoted with `"` and
`\` escaped. Added validateRedirectUri() per RFC 6749 §3.1.2.1:
redirect_uris must be https:// or loopback (localhost / 127.0.0.1).
Wired into the DCR registerClient path; CLI registration trusts the
operator and bypasses. Regression test confirms a comma-in-URI element
round-trips as 1 element, not 2.
#6 --public-url flag — issuerUrl was hardcoded to http://localhost:{port}.
Behind reverse proxies / ngrok / production deploys, the issuer claim
in tokens wouldn't match the discovery URL clients hit (RFC 8414 §3.3).
New --public-url URL flag on `gbrain serve --http`, propagates through
serve.ts → serve-http.ts → ServeHttpOptions.publicUrl → issuerUrl.
Startup banner surfaces the configured issuer.
Findings #4 (admin requests filter dead code), #7 (admin register-client
hardcoded grant_types), #8 (legacy token grandfathering posture) are
documentation / minor functional fixes and are deferred per user direction.
Tests: oauth.test.ts now 34 cases (was 27). 7 new:
- single-use TOCTOU regression (10 concurrent code exchanges)
- single-use TOCTOU regression (10 concurrent refresh exchanges)
- redirect_uri http://localhost passes
- redirect_uri https://example.com passes
- redirect_uri http://example.com (non-loopback plaintext) rejected
- redirect_uri non-URL rejected
- redirect_uri with embedded comma stored as single element
Files:
- VERSION, package.json: 0.22.0 → 0.26.0
- CHANGELOG.md: heading + table + "To take advantage" + "pre-v0.22" → v0.26;
new "Security hardening (post-/cso pass)" subsection at top of itemized
changes; CLI flag list updated for --public-url.
- src/core/oauth-provider.ts: pgArray escape, validateRedirectUri,
registerClient enforces validation, DELETE...RETURNING in
exchangeAuthorizationCode + exchangeRefreshToken.
- src/commands/serve-http.ts: cookie-parser import + wire-up,
publicUrl option, issuerUrl honors it, startup banner shows issuer.
- src/commands/serve.ts: parses --public-url and threads through.
- src/cli.ts: help text adds --public-url URL flag.
- test/oauth.test.ts: +7 regression tests (now 34 total).
- llms-full.txt: regenerated.
Typecheck clean. 34 oauth + 14 cli tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
c2ae4dbfc5
commit
3c032d79ec
@@ -11,6 +11,8 @@ bin/
|
||||
.gstack/
|
||||
supabase/.temp/
|
||||
.claude/skills/
|
||||
admin/dist/
|
||||
admin/node_modules/
|
||||
.idea
|
||||
eval/reports/
|
||||
eval/data/world-v1/world.html
|
||||
|
||||
@@ -18,7 +18,13 @@ start here.
|
||||
1. `./AGENTS.md` (this file) — install + operating protocol.
|
||||
2. [`./CLAUDE.md`](./CLAUDE.md) — architecture reference, key files, trust boundaries,
|
||||
test layout.
|
||||
3. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
|
||||
3. [`./docs/architecture/brains-and-sources.md`](./docs/architecture/brains-and-sources.md)
|
||||
— the two-axis mental model (brain = which DB, source = which repo in the DB). Every
|
||||
query routes on both axes. Read before writing anything that touches brain ops.
|
||||
4. [`./skills/conventions/brain-routing.md`](./skills/conventions/brain-routing.md) —
|
||||
agent-facing decision table: when to switch brain, when to switch source, how
|
||||
cross-brain federation works (latent-space only; the agent decides).
|
||||
5. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
|
||||
|
||||
## Trust boundary (critical)
|
||||
|
||||
|
||||
+114
@@ -2,6 +2,120 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.26.0] - 2026-04-25
|
||||
|
||||
## **Multi-agent MCP is real. OAuth 2.1, HTTP server, React admin dashboard. Ship once, every AI client connects.**
|
||||
## **`gbrain serve --http` starts a production-grade OAuth server with embedded admin UI. Zero external dependencies.**
|
||||
|
||||
This is GBrain as an organizational knowledge layer. Multiple AI agents, authenticated with scoped tokens, hitting the same brain over the wire. Perplexity Computer writes research. Claude queries for context. ChatGPT calls tools. Every request authenticated, every scope enforced, every action logged. One binary, zero infrastructure.
|
||||
|
||||
OAuth 2.1 via the MCP SDK's built-in infrastructure (`mcpAuthRouter` + `OAuthServerProvider`). Full spec compliance: client credentials for machine-to-machine (Perplexity, Claude), authorization code with PKCE for browser-based clients (ChatGPT), token refresh with rotation, dynamic client registration (default off behind `--enable-dcr` flag), token revocation, protected resource metadata. 30 operations tagged with `scope: 'read' | 'write' | 'admin'`, enforced in the HTTP transport before dispatch. `sync_brain` and `file_upload` are `localOnly` ... admin-scoped AND excluded from HTTP, so remote agents cannot reach local filesystem surface area.
|
||||
|
||||
React admin dashboard baked into the binary. Seven screens designed through Steve Krug's "Don't Make Me Think" lens: login, dashboard with live activity SSE feed, agents table with sparklines, register agent modal with scope checkboxes, credentials reveal with copy buttons and JSON download, filterable request log with pagination, agent detail drawer with per-client config export. Dark theme, JetBrains Mono for data, Inter for UI. Dense utilitarian layout. HTTP-only SameSite=Strict cookie auth with bootstrap token printed to the terminal on first start. 65KB gzipped.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
7 bisectable commits on this branch before the merge. 27 dedicated OAuth tests, all pass. Full suite: 2068 pass / 18 pre-existing master timeouts (unchanged from the merge). Typecheck clean.
|
||||
|
||||
| Metric | BEFORE v0.26 | AFTER v0.26 | Δ |
|
||||
|---|---|---|---|
|
||||
| Auth mechanism | static bearer tokens | OAuth 2.1 + legacy fallback | protocol-compliant |
|
||||
| Concurrent agents | 1 (stdio only) | many (HTTP) | unbounded |
|
||||
| ChatGPT support | impossible (needs OAuth + PKCE) | native | unblocked |
|
||||
| Admin surface | CLI-only | /admin dashboard | +7 screens |
|
||||
| Scoped operations | 0 | 30 (all) | +30 |
|
||||
| New tests | ... | 27 (oauth.test.ts) | +27 |
|
||||
|
||||
### What this means for agents
|
||||
|
||||
`gbrain auth register-client perplexity --grant-types client_credentials --scopes "read write"` gives you credentials. `gbrain serve --http --port 3131` starts the server, prints the admin bootstrap token. Open `localhost:3131/admin`, paste the token, watch the live feed. Every Perplexity search, every Claude query, every ChatGPT tool call streams into the dashboard in real time. You see who's connected, what they're doing, and where the errors are. The thing actually works, this isn't a stepping stone, it's the production surface.
|
||||
|
||||
## To take advantage of v0.26.0
|
||||
|
||||
`gbrain upgrade` should run the schema migration automatically. If it didn't, or if `gbrain doctor` warns about a partial migration:
|
||||
|
||||
1. **Run the orchestrator manually:**
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
2. **Verify OAuth tables exist:**
|
||||
```bash
|
||||
gbrain doctor
|
||||
```
|
||||
3. **Register your first OAuth agent:**
|
||||
```bash
|
||||
gbrain auth register-client perplexity --grant-types client_credentials --scopes "read write"
|
||||
```
|
||||
4. **Start the HTTP server:**
|
||||
```bash
|
||||
gbrain serve --http --port 3131
|
||||
```
|
||||
The terminal prints the admin bootstrap token. Open `http://localhost:3131/admin` and paste it to access the dashboard.
|
||||
5. **If any step fails,** please file an issue: https://github.com/garrytan/gbrain/issues with:
|
||||
- output of `gbrain doctor`
|
||||
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
- which step broke
|
||||
|
||||
### Itemized changes
|
||||
|
||||
**Security hardening (post-/cso pass):**
|
||||
- Auth code exchange + refresh token rotation now use atomic `DELETE...RETURNING` instead of SELECT-then-DELETE. The earlier non-atomic pattern let two concurrent token requests with the same auth code both succeed, issuing two valid token pairs from one code (RFC 6749 §10.5 violation). Same shape applied to refresh tokens (RFC 6749 §10.4 detection of stolen tokens depends on second-use failure). New regression tests fire 10 concurrent requests with the same code/refresh and assert exactly one succeeds.
|
||||
- `pgArray()` now escapes commas, braces, quotes, and backslashes inside array elements. The earlier no-escape join could be exploited (with `--enable-dcr` on) to smuggle a second redirect_uri into a registered client's array, enabling auth code redirection to an attacker-controlled domain.
|
||||
- Dynamic Client Registration now enforces RFC 6749 §3.1.2.1: every `redirect_uri` must be `https://` or loopback (`http://localhost`, `http://127.0.0.1`). Plaintext non-loopback `http://` is rejected at registration time.
|
||||
- `serve --http` now accepts `--public-url URL` to set the OAuth issuer in discovery metadata. Required for production deployments behind reverse proxies, ngrok tunnels, or any non-loopback URL — the issuer claim must match the discovery URL clients hit (RFC 8414 §3.3).
|
||||
- `cookie-parser` middleware now wired in. The admin dashboard auth was silently broken in the original v0.22 ship: `/admin/login` set the cookie, but every subsequent admin API call returned 401 because Express 5 has no built-in cookie parsing. Direct dep added: `cookie-parser@^1.4.7`.
|
||||
|
||||
**OAuth 2.1 (new):**
|
||||
- `src/core/oauth-provider.ts` (404 lines) ... `GBrainOAuthProvider` implementing MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres).
|
||||
- All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL. Refresh tokens rotate on use. Client credentials grant issues access token only (no refresh per RFC 6749 §4.4.3).
|
||||
- Legacy `access_tokens` fallback: pre-v0.26 bearer tokens continue working, grandfathered as `read+write+admin` scopes.
|
||||
- `sweepExpiredTokens()` runs on startup wrapped in try/catch ... server boots even if the sweep fails.
|
||||
- `hashToken()` and `generateToken()` extracted to `src/core/utils.ts` (DRY across auth surfaces).
|
||||
|
||||
**HTTP server (new):**
|
||||
- `src/commands/serve-http.ts` ... Express 5 server with `mcpAuthRouter` + custom client_credentials handler (SDK's token endpoint throws `UnsupportedGrantTypeError` for CC; our handler runs BEFORE the router, falls through to SDK for auth_code/refresh).
|
||||
- Rate limiting on `/token` (50 requests / 15 min per IP via `express-rate-limit`).
|
||||
- Admin dashboard served from `admin/dist/` via Express static + SPA fallback.
|
||||
- SSE endpoint at `/admin/events` broadcasts every MCP request to connected admin browsers.
|
||||
- CORS: `/mcp` + `/token` open, `/admin` same-origin.
|
||||
- Startup logging prints port, engine type, registered client count, DCR status, issuer URL, admin bootstrap token.
|
||||
|
||||
**Schema (new tables):**
|
||||
- `oauth_clients` (client_id, hashed secret, grant_types array, scope, redirect_uris, timestamps)
|
||||
- `oauth_tokens` (token hash, type=access|refresh, client_id, scopes, expires_at, resource)
|
||||
- `oauth_codes` (code hash, client_id, scopes, PKCE challenge, redirect_uri, state, expires_at)
|
||||
- Composite index on `mcp_request_log(created_at, token_name)` for the admin dashboard's time-range queries.
|
||||
- Migration v30 (`oauth_infrastructure`) creates everything idempotently. PGLite schema updated to include auth infrastructure because `serve --http` makes it network-accessible.
|
||||
|
||||
**Operation contract:**
|
||||
- `Operation.scope?: 'read' | 'write' | 'admin'` ... added to interface, annotated on all 30 operations plus 11 new Minion ops via per-op audit (not derived from `mutating` flag).
|
||||
- `Operation.localOnly?: boolean` ... marks operations rejected over HTTP. `sync_brain`, `file_upload`, `file_list`, `file_url` all `admin + localOnly`.
|
||||
- `OperationContext.auth?: AuthInfo` ... threaded through HTTP dispatch for scope enforcement.
|
||||
|
||||
**React admin dashboard (new `admin/`):**
|
||||
- Vite + React 19, TypeScript, 65KB gzipped.
|
||||
- 7 screens: Login, Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes), Credentials (full-screen modal with Copy + Download JSON + yellow warning), Request Log (filterable paginated table), Agent Detail (drawer with Details/Activity/Config Export tabs + Revoke).
|
||||
- Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges.
|
||||
- Interaction states: empty state CTAs, SSE reconnection messaging, credential-reveal warning ("Save this secret now. It will not be shown again.").
|
||||
- Design lens: Steve Krug "Don't Make Me Think" ... zero happy talk, mindless choices, scannable tables, billboard-speed comprehension.
|
||||
|
||||
**CLI:**
|
||||
- `gbrain serve --http [--port 3131] [--token-ttl 3600] [--enable-dcr] [--public-url URL]` ... new HTTP transport alongside existing stdio `gbrain serve`.
|
||||
- `gbrain auth register-client <name> --grant-types <types> --scopes <scopes>` ... manual OAuth client registration.
|
||||
- Existing `auth create/list/revoke` kept for backward compatibility.
|
||||
|
||||
**Dependencies:**
|
||||
- `express@^5.1.0`, `express-rate-limit@^7.5.0`, `cors@^2.8.5`, `cookie-parser@^1.4.7` as direct deps.
|
||||
- `@modelcontextprotocol/sdk` pinned to exact `1.29.0` (was `^1.0.0`).
|
||||
- `@types/express`, `@types/cors`, `@types/cookie-parser` as dev deps for typecheck.
|
||||
|
||||
**Tests:**
|
||||
- `test/oauth.test.ts` ... 34 test cases covering provider: register, getClient, client_credentials exchange, auth_code flow with PKCE, refresh rotation, verifyAccessToken (OAuth + legacy fallback), revokeToken, sweepExpiredTokens, scope annotations on all 30 operations. Plus the post-/cso security-fix regressions: 10-concurrent auth code exchange (only 1 wins), 10-concurrent refresh rotation (only 1 wins), redirect_uri HTTPS-or-loopback gate, and pgArray comma-element round-trip (1 element in → 1 element out).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.25.1] - 2026-05-01
|
||||
|
||||
## **Your brain can now read books with you. Nine new skills land at once.**
|
||||
|
||||
@@ -7,6 +7,24 @@ suggests Supabase for 1000+ files. GStack teaches agents how to code. GBrain tea
|
||||
agents everything else: brain ops, signal detection, content ingestion, enrichment,
|
||||
cron scheduling, reports, identity, and access control.
|
||||
|
||||
## Two organizational axes (read this first)
|
||||
|
||||
GBrain knowledge is organized along two orthogonal axes. Users AND agents must
|
||||
understand both, or queries misroute silently.
|
||||
|
||||
- **Brain** — WHICH DATABASE. Your personal brain is `host`. You can mount
|
||||
additional brains (team-published, each with their own DB and access policy)
|
||||
via `gbrain mounts add` (v0.19+). Routing: `--brain`, `GBRAIN_BRAIN_ID`,
|
||||
`.gbrain-mount` dotfile.
|
||||
- **Source** — WHICH REPO INSIDE THE DATABASE. A brain can hold many sources
|
||||
(wiki, gstack, openclaw, essays). Slugs scope per source. Routing:
|
||||
`--source`, `GBRAIN_SOURCE`, `.gbrain-source` dotfile.
|
||||
|
||||
Both axes follow the same 6-tier resolution pattern. Read
|
||||
`docs/architecture/brains-and-sources.md` for topology diagrams (personal, team
|
||||
mount, CEO-class with multiple team brains) and
|
||||
`skills/conventions/brain-routing.md` for the agent-facing decision table.
|
||||
|
||||
## Architecture
|
||||
|
||||
Contract-first: `src/core/operations.ts` defines ~41 shared operations (adds `find_orphans` in v0.12.3). CLI and MCP
|
||||
@@ -22,7 +40,7 @@ strict behavior when unset.
|
||||
|
||||
## Key files
|
||||
|
||||
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `<prefix>/*` matches recursive children; bare `<prefix>` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents/<id>/...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`).
|
||||
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `<prefix>/*` matches recursive children; bare `<prefix>` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents/<id>/...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`). As of v0.26.0, every `Operation` also carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`. All ops are annotated; `sync_brain`, `file_upload`, `file_list`, and `file_url` are `admin + localOnly` (rejected over HTTP). `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs.
|
||||
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
|
||||
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
|
||||
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains.
|
||||
@@ -108,10 +126,12 @@ strict behavior when unset.
|
||||
- `src/commands/features.ts` — `gbrain features --json --auto-fix`: usage scan + feature adoption salesman
|
||||
- `src/commands/autopilot.ts` — `gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
|
||||
- `src/mcp/server.ts` — MCP stdio server (generated from operations). v0.22.7: tool-call handler delegates to `dispatchToolCall` from `src/mcp/dispatch.ts` so stdio + HTTP transports share one validation, context-build, and error-format path.
|
||||
- `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP (`http-transport.ts`). Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1 (reversed handler args) + F2 (incomplete OperationContext) + F3 (no param validation) drift bugs in the original v0.22.5 HTTP transport.
|
||||
- `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter for `gbrain serve --http`. `buildDefaultLimiters()` returns the two-bucket pipeline used by http-transport: pre-auth IP (default 30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (default 60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap (default 10K keys) bounds memory under attacker-controlled key growth; TTL prune at 2× window evicts abandoned buckets.
|
||||
- `src/mcp/http-transport.ts` (v0.22.7, rewrite) — `gbrain serve --http` HTTP transport. Postgres-only — fails fast at startup on PGLite (the `access_tokens` table only exists on Postgres). Bearer auth against SHA-256 hashes in `access_tokens`. CORS default-deny via `GBRAIN_HTTP_CORS_ORIGIN` allowlist. Body cap stream-counted (1 MiB default via `GBRAIN_HTTP_MAX_BODY_BYTES`) so chunked transfers without Content-Length still hit the cap. `last_used_at` SQL-level debounce (one UPDATE per token per 60s). Per-request audit row in `mcp_request_log` with token_name + operation + status + latency. Optional `GBRAIN_HTTP_TRUST_PROXY=1` honors `X-Forwarded-For` — only safe when bound to a private interface AND the proxy strips client-supplied XFF (otherwise enables IP spoofing past the pre-auth rate limit). `/health` does `SELECT 1` against Postgres and returns 503 + `status:unhealthy` when the DB is unreachable so orchestration doesn't see green pods while clients get misleading 401s. Replaces the standalone OAuth wrapper that was vulnerable to unauthenticated client registration.
|
||||
- `src/commands/auth.ts` — Token management for the HTTP transport. `gbrain auth create/list/revoke/test`. As of v0.22.7 wired into the main CLI (`src/cli.ts`); also runs standalone via `bun run src/commands/auth.ts ...` for environments without a compiled binary. Tokens stored as SHA-256 hashes in `access_tokens` (Postgres-only).
|
||||
- `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP transports. Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1/F2/F3 drift bugs in the original v0.22.5 HTTP transport.
|
||||
- `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter. `buildDefaultLimiters()` returns the two-bucket pipeline: pre-auth IP (30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap bounds memory under attacker-controlled key growth.
|
||||
- `src/commands/serve-http.ts` (v0.26.0) — Express 5 HTTP MCP server with OAuth 2.1, admin dashboard, and SSE live activity feed. Started via `gbrain serve --http [--port N] [--token-ttl N] [--enable-dcr] [--public-url URL]`. Supersedes the v0.22.7 `src/mcp/http-transport.ts` simple bearer-auth path. Combines MCP SDK's `mcpAuthRouter` (authorize / token / register / revoke endpoints), a custom `client_credentials` handler (SDK's token endpoint throws `UnsupportedGrantTypeError` for CC; the custom handler runs BEFORE the router and falls through for `auth_code` / `refresh_token`), `requireBearerAuth` middleware for `/mcp` with scope enforcement before op dispatch, `localOnly` rejection, and `express-rate-limit` at 50 req / 15 min on `/token`. Serves the built admin SPA from `admin/dist/` with SPA fallback. `/admin/events` SSE endpoint broadcasts every MCP request to connected admin browsers. `cookie-parser` middleware wired (Express 5 has no built-in). Startup logging prints port, engine, configured issuer URL (honors `--public-url`), registered-client count, DCR status, and admin bootstrap token.
|
||||
- `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch.
|
||||
- `admin/` (v0.26.0) — React 19 + Vite + TypeScript admin SPA embedded in the binary via `admin/dist/` served by `serve-http.ts`. 7 screens: Login (bootstrap token → session cookie), Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes + grant type selector), Credentials reveal (full-screen modal with Copy + Download JSON + yellow one-time-only warning), Request Log (filterable paginated), Agent Detail drawer (Details / Activity / Config Export tabs + Revoke). Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges. HTTP-only SameSite=Strict cookie auth. 65KB gzip. Build: `cd admin && bun install && bun run build`; output at `admin/dist/` is committed for self-contained binaries.
|
||||
- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) for OAuth 2.1 client registration. Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration.
|
||||
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
|
||||
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
|
||||
- `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
|
||||
@@ -246,6 +266,15 @@ Key commands added in v0.14.2:
|
||||
- `GBRAIN_POOL_SIZE` env var — honored by both the singleton pool (`src/core/db.ts`) and the parallel-import worker pool (`src/commands/import.ts`). Default is 10; lower to 2 for Supabase transaction pooler to avoid MaxClients crashes during `gbrain upgrade` subprocess spawns. Read at call time via `resolvePoolSize()`.
|
||||
- `gbrain doctor` gains two new checks: `sync_failures` (surfaces unacknowledged parse failures with exact paths + fix hints) and `brain_score` (renders the 5-component breakdown when score < 100: embed coverage / 35, link density / 25, timeline coverage / 15, orphans / 15, dead links / 10 — sum equals total).
|
||||
|
||||
Key commands added in v0.26.0 (OAuth 2.1 + HTTP server + admin dashboard):
|
||||
- `gbrain serve --http [--port 3131] [--token-ttl 3600] [--enable-dcr]` — HTTP MCP server with OAuth 2.1, admin dashboard at `/admin`, SSE activity feed at `/admin/events`, health check at `/health`. Prints admin bootstrap token on first start. Alongside (not replacing) stdio `gbrain serve`.
|
||||
- **OAuth client registration** — three paths:
|
||||
1. CLI: `gbrain auth register-client <name> --grant-types <types> --scopes <scopes>` (wired into `src/commands/auth.ts` as a thin wrapper over `GBrainOAuthProvider.registerClientManual`). Default grant types: `client_credentials`. Default scopes: `read`.
|
||||
2. Admin dashboard: Register client modal → credential reveal with Copy + Download JSON.
|
||||
3. SDK: `oauthProvider.registerClientManual(name, grantTypes, scopes, redirectUris)` for programmatic wrappers.
|
||||
`--enable-dcr` on `serve --http` opens the `/register` endpoint for RFC 7591 self-service registration (off by default).
|
||||
- `gbrain auth create|list|revoke|test` — legacy bearer tokens still work and grandfather to `read+write+admin` scopes on the OAuth server. `auth` is wired as a first-class `gbrain` subcommand in v0.26.0 (previously only invokable via `bun run src/commands/auth.ts`). No migration required to keep pre-v0.26 clients working.
|
||||
|
||||
Key commands added in v0.14.3 (fix wave):
|
||||
- `gbrain doctor --index-audit` — opt-in Postgres-only check reporting zero-scan indexes from `pg_stat_user_indexes`. Informational only; never auto-drops.
|
||||
- `gbrain doctor` schema_version check fails loudly when `version=0` — catches `bun install -g github:...` postinstall failures (#218) and routes users to `gbrain apply-migrations --yes`.
|
||||
@@ -327,6 +356,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
|
||||
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
|
||||
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
|
||||
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases),
|
||||
`test/oauth.test.ts` (v0.26.0 OAuth 2.1 provider — 27 cases: register, getClient, `client_credentials` grant exchange, `authorization_code` flow with PKCE challenge / verifier, refresh token rotation, `verifyAccessToken` with both OAuth + legacy `access_tokens` fallback, `revokeToken`, `sweepExpiredTokens`, and a contract test asserting `scope` + `localOnly` annotations are set correctly on all 30 operations),
|
||||
`test/check-resolvable-cli.test.ts` (v0.19 CLI wrapper: exit codes, JSON envelope shape, AGENTS.md fallback chain),
|
||||
`test/regression-v0_16_4.test.ts` (findRepoRoot regression guard — hermetic startDir parameterization),
|
||||
`test/filing-audit.test.ts` (v0.19 Check 6: `writes_pages` / `writes_to` frontmatter, filing-rules JSON validation),
|
||||
|
||||
@@ -79,16 +79,36 @@ GBrain exposes 30+ MCP tools via stdio:
|
||||
|
||||
Add to `~/.claude/server.json` (Claude Code), Settings > MCP Servers (Cursor), or your client's MCP config.
|
||||
|
||||
### Remote MCP (Claude Desktop, Cowork, Perplexity)
|
||||
### Remote MCP with OAuth 2.1 (ChatGPT, Claude Desktop, Cowork, Perplexity)
|
||||
|
||||
`gbrain serve --http` starts a production-grade OAuth 2.1 server with an embedded admin dashboard. Zero external infrastructure. Every major AI client connects, every request is scoped, every action is logged.
|
||||
|
||||
```bash
|
||||
gbrain auth create "claude-desktop" # tokens via the existing CLI
|
||||
gbrain serve --http --port 8787 # built-in HTTP transport (Postgres-only)
|
||||
ngrok http 8787 --url your-brain.ngrok.app # any tunnel works
|
||||
# Start the HTTP server (prints admin bootstrap token on first start)
|
||||
gbrain serve --http --port 3131
|
||||
|
||||
# Open the admin dashboard, paste the bootstrap token, register a client
|
||||
open http://localhost:3131/admin
|
||||
|
||||
# Expose publicly (set --public-url so the OAuth issuer matches)
|
||||
ngrok http 3131 --url your-brain.ngrok.app
|
||||
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app
|
||||
|
||||
# ChatGPT and other OAuth-aware clients can also connect:
|
||||
claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization: Bearer TOKEN"
|
||||
```
|
||||
|
||||
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). Hardening defaults, env vars, and threat model: [SECURITY.md](SECURITY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
|
||||
Register OAuth clients from the `/admin` dashboard — click **Register client**,
|
||||
pick scopes, save the credentials shown once in the reveal modal. Programmatic
|
||||
registration via `oauthProvider.registerClientManual(...)` and the
|
||||
`gbrain auth register-client` CLI are also available.
|
||||
|
||||
- **OAuth 2.1 via the MCP SDK** — client credentials (machine-to-machine: Perplexity, Claude), authorization code + PKCE (browser-based: ChatGPT), refresh token rotation, revocation, protected resource metadata. Optional Dynamic Client Registration behind `--enable-dcr` (DCR redirect_uris must be `https://` or loopback per RFC 6749 §3.1.2.1).
|
||||
- **Scoped operations** — 30 operations tagged `read | write | admin`. `sync_brain` and `file_upload` are `localOnly`, rejected over HTTP.
|
||||
- **React admin dashboard** — 7 screens baked into the binary (~65KB gzip). Live SSE activity feed, agents table, credential reveal, filterable request log, per-client config export.
|
||||
- **Legacy bearer tokens still work** — pre-v0.26 `gbrain auth create` tokens continue to authenticate as `read+write+admin`. v0.22.7's simpler `src/mcp/http-transport.ts` path stays compiled in for backward compat callers; v0.26+ deployments use the OAuth-aware `serve-http.ts`.
|
||||
|
||||
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). Hardening defaults, env vars, and threat model: [SECURITY.md](SECURITY.md).
|
||||
|
||||
### Using gbrain with GStack
|
||||
|
||||
@@ -707,8 +727,15 @@ ADMIN
|
||||
gbrain doctor --locks List idle-in-tx backends (57014 diagnostic, Postgres only)
|
||||
gbrain stats Brain statistics
|
||||
gbrain serve MCP server (stdio)
|
||||
gbrain serve --http --port 8787 MCP server (HTTP, Postgres-only, bearer auth)
|
||||
gbrain auth create|list|revoke|test Token management for the HTTP transport
|
||||
gbrain serve --http [--port 3131] HTTP MCP server with OAuth 2.1 + admin dashboard
|
||||
[--token-ttl 3600] [--enable-dcr]
|
||||
[--public-url URL]
|
||||
gbrain auth create|list|revoke|test Legacy bearer token management
|
||||
gbrain auth register-client <name> Register an OAuth 2.1 client
|
||||
--grant-types client_credentials,authorization_code
|
||||
--scopes "read write admin"
|
||||
# OAuth 2.1 clients can also be registered from the /admin dashboard or
|
||||
# programmatically via oauthProvider.registerClientManual() for host-repo wrappers.
|
||||
gbrain integrations Integration recipe dashboard
|
||||
gbrain sources list|add|remove|... Multi-source brain management (v0.18)
|
||||
gbrain dream [--dry-run] [--phase N] 8-phase maintenance cycle (lint→backlinks→sync→synthesize
|
||||
|
||||
@@ -715,19 +715,6 @@ iteration's residuals.
|
||||
|
||||
**Depends on:** PGLite engine shipping (to have a real use case for the PR).
|
||||
|
||||
### ChatGPT MCP support (OAuth 2.1)
|
||||
**What:** Add OAuth 2.1 with Dynamic Client Registration to the self-hosted MCP server so ChatGPT can connect.
|
||||
|
||||
**Why:** ChatGPT requires OAuth 2.1 for MCP connectors. Bearer token auth is NOT supported. This is the only major AI client that can't use GBrain remotely.
|
||||
|
||||
**Pros:** Completes the "every AI client" promise. ChatGPT has the largest user base.
|
||||
|
||||
**Cons:** OAuth 2.1 is a significant implementation: authorization endpoint, token endpoint, PKCE flow, dynamic client registration. Estimated CC: ~3-4 hours.
|
||||
|
||||
**Context:** Discovered during DX review (2026-04-10). All other clients (Claude Desktop/Code/Cowork, Perplexity) work with bearer tokens. The Edge Function deployment was removed in v0.8.0. OAuth needs to be added to the self-hosted HTTP MCP server (or `gbrain serve --http` when implemented).
|
||||
|
||||
**Depends on:** `gbrain serve --http` (not yet implemented).
|
||||
|
||||
### Runtime MCP access control
|
||||
**What:** Add sender identity checking to MCP operations. Brain ops return filtered data based on access tier (Full/Work/Family/None).
|
||||
|
||||
@@ -766,6 +753,22 @@ iteration's residuals.
|
||||
### ~~Constrained health_check DSL for third-party recipes~~
|
||||
**Completed:** v0.9.3 (2026-04-12). Typed DSL with 4 check types (`http`, `env_exists`, `command`, `any_of`). All 7 first-party recipes migrated. String health checks accepted with deprecation warning + metachar validation for non-embedded recipes.
|
||||
|
||||
## P1 (new from v0.18.0 — test flakiness)
|
||||
|
||||
### beforeAll hook timeouts under parallel test runner
|
||||
**What:** 17 tests across 9 files (dream, orphans, brain-allowlist, extract-db, multi-source-integration, core/cycle, migrations-v0_12_2, migrations-v0_13_1, oauth) fail with `beforeEach/afterEach hook timed out for this test` at the 7-10 second threshold when run via `bun run test` (parallel). Every test passes in isolation (`bun test path/to/file.test.ts` → 0 fail). Root cause is PGLite schema init racing under concurrent test files.
|
||||
|
||||
**Why:** `bun run test` is the pre-ship gate and reports these as failures, forcing manual triage on every /ship. The tests themselves are correct — the runner is stressing PGLite boot. Bumping the hook timeout or running E2E-like tests with `--bail` or serial execution would clear the 18 false positives.
|
||||
|
||||
**Fix options:**
|
||||
1. Bump per-test hook timeout to 30s in `bunfig.toml` (quick fix, low risk)
|
||||
2. Move PGLite-init-heavy tests to `test/e2e/` so they run serially via `scripts/run-e2e.sh` (follows existing pattern)
|
||||
3. Share a module-scoped PGLite instance across describe blocks within a file (biggest win — most fixture setup is identical)
|
||||
|
||||
**Effort:** 30 min for option 1, ~2 hours for option 3.
|
||||
|
||||
**Context:** Noticed during /ship merge wave on `garrytan/mcp-key-mgmt` (2026-04-16 branch merge of v0.18.0). Failure set stayed exactly 17-18 tests across multiple /ship runs, confirming deterministic flakes rather than real regressions. Blocking workaround: run the specific test file to verify after any suite change.
|
||||
|
||||
## P1 (new from v0.11.0 — Minions)
|
||||
|
||||
### Per-queue rate limiting for Minions
|
||||
@@ -996,6 +999,9 @@ iteration's residuals.
|
||||
|
||||
## Completed
|
||||
|
||||
### ChatGPT MCP support (OAuth 2.1)
|
||||
**Completed:** v0.26.0 (2026-04-25) — `gbrain serve --http` ships full OAuth 2.1 via MCP SDK's `mcpAuthRouter` + `OAuthServerProvider`. Authorization code flow with PKCE unblocks ChatGPT. Client credentials flow unblocks Perplexity/Claude. Dynamic Client Registration available behind `--enable-dcr` flag (off by default). See `docs/mcp/CHATGPT.md` for connector setup. Closed the P0 that had been blocking the "every AI client" promise since v0.6.
|
||||
|
||||
### Implement AWS Signature V4 for S3 storage backend
|
||||
**Completed:** v0.6.0 (2026-04-10) — replaced with @aws-sdk/client-s3 for proper SigV4 signing.
|
||||
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "gbrain-admin",
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.336", "", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GBrain Admin</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "gbrain-admin",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"vite": "^6.3.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LoginPage } from './pages/Login';
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { AgentsPage } from './pages/Agents';
|
||||
import { RequestLogPage } from './pages/RequestLog';
|
||||
|
||||
type Page = 'login' | 'dashboard' | 'agents' | 'log';
|
||||
|
||||
function getPage(): Page {
|
||||
const hash = window.location.hash.replace('#', '') || 'dashboard';
|
||||
if (['login', 'dashboard', 'agents', 'log'].includes(hash)) return hash as Page;
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [page, setPage] = useState<Page>(getPage);
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => setPage(getPage());
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, []);
|
||||
|
||||
const navigate = (p: Page) => {
|
||||
window.location.hash = p;
|
||||
setPage(p);
|
||||
};
|
||||
|
||||
if (page === 'login') {
|
||||
return <LoginPage onLogin={() => navigate('dashboard')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-logo">GBrain</div>
|
||||
<div className="sidebar-nav">
|
||||
<a className={`nav-item ${page === 'dashboard' ? 'active' : ''}`}
|
||||
onClick={() => navigate('dashboard')}>Dashboard</a>
|
||||
<a className={`nav-item ${page === 'agents' ? 'active' : ''}`}
|
||||
onClick={() => navigate('agents')}>Agents</a>
|
||||
<a className={`nav-item ${page === 'log' ? 'active' : ''}`}
|
||||
onClick={() => navigate('log')}>Request Log</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="main">
|
||||
{page === 'dashboard' && <DashboardPage />}
|
||||
{page === 'agents' && <AgentsPage />}
|
||||
{page === 'log' && <RequestLogPage />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const BASE = '';
|
||||
|
||||
async function apiFetch(path: string, options?: RequestInit) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
window.location.hash = '#login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (token: string) => apiFetch('/admin/login', { method: 'POST', body: JSON.stringify({ token }) }),
|
||||
stats: () => apiFetch('/admin/api/stats'),
|
||||
health: () => apiFetch('/admin/api/health-indicators'),
|
||||
agents: () => apiFetch('/admin/api/agents'),
|
||||
requests: (page = 1) => apiFetch(`/admin/api/requests?page=${page}`),
|
||||
};
|
||||
@@ -0,0 +1,356 @@
|
||||
:root {
|
||||
--bg-primary: #0a0a0f;
|
||||
--bg-secondary: #14141f;
|
||||
--bg-tertiary: #1e1e2e;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #888;
|
||||
--text-muted: #555;
|
||||
--accent: #3b82f6;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.app { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid #1e1e2e;
|
||||
padding: 16px 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.nav-item.active {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.main { flex: 1; padding: 24px 32px; overflow-y: auto; }
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Metrics bar */
|
||||
.metrics { display: flex; gap: 16px; margin-bottom: 24px; }
|
||||
.metric {
|
||||
background: var(--bg-secondary);
|
||||
padding: 16px 20px;
|
||||
border-radius: 6px;
|
||||
min-width: 140px;
|
||||
}
|
||||
.metric-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.metric-label { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border-top: 1px solid #1a1a2a;
|
||||
}
|
||||
tr:hover td { background: var(--bg-tertiary); }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-read { background: rgba(59,130,246,0.15); color: var(--accent); }
|
||||
.badge-write { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||||
.badge-admin { background: rgba(239,68,68,0.15); color: var(--error); }
|
||||
.badge-success { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.badge-error { background: rgba(239,68,68,0.15); color: var(--error); }
|
||||
|
||||
/* Status dots */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.status-active { background: var(--success); }
|
||||
.status-warning { background: var(--warning); }
|
||||
.status-inactive { background: var(--text-muted); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-secondary { background: transparent; color: var(--text-secondary); border: 1px solid #333; }
|
||||
.btn-secondary:hover { border-color: var(--text-secondary); color: var(--text-primary); }
|
||||
.btn-danger { background: transparent; color: var(--error); border: 1px solid var(--error); }
|
||||
.btn-danger:hover { background: rgba(239,68,68,0.1); }
|
||||
|
||||
/* Forms */
|
||||
input, select {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid #333;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,0.2);
|
||||
}
|
||||
input::placeholder { color: var(--text-muted); }
|
||||
label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
min-width: 420px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; }
|
||||
|
||||
/* Drawer */
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 90;
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 420px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--accent);
|
||||
padding: 24px;
|
||||
z-index: 91;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.drawer-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
margin: 20px 0 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Health panel */
|
||||
.health-panel {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
.health-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.code-block {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
.code-block .copy-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Activity feed */
|
||||
.feed { max-height: 400px; overflow-y: auto; }
|
||||
.feed-empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Sparkline */
|
||||
.sparkline { display: inline-block; vertical-align: middle; }
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
|
||||
.filter-bar select { width: auto; min-width: 140px; }
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.pagination button {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid #333;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pagination button:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* Warning bar */
|
||||
.warning-bar {
|
||||
background: rgba(245,158,11,0.15);
|
||||
border: 1px solid var(--warning);
|
||||
color: var(--warning);
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.checkbox-group { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 12px; }
|
||||
.tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* Login page */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.login-box { text-align: center; width: 340px; }
|
||||
.login-logo { font-size: 32px; font-weight: 600; margin-bottom: 32px; }
|
||||
.login-hint { color: var(--text-muted); font-size: 12px; margin-top: 12px; }
|
||||
.login-error { color: var(--error); font-size: 13px; margin-top: 8px; }
|
||||
|
||||
/* Monospace data */
|
||||
.mono { font-family: var(--font-mono); font-size: 12px; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.main { padding: 16px; }
|
||||
.metrics { flex-wrap: wrap; }
|
||||
.drawer { width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
interface Agent {
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
grant_types: string[];
|
||||
scope: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [showCredentials, setShowCredentials] = useState<{ clientId: string; clientSecret: string; name: string } | null>(null);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
|
||||
useEffect(() => { loadAgents(); }, []);
|
||||
|
||||
const loadAgents = () => {
|
||||
api.agents().then(setAgents).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>Agents</h1>
|
||||
<button className="btn btn-primary" onClick={() => setShowRegister(true)}>+ Register Agent</button>
|
||||
</div>
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted)' }}>
|
||||
No agents registered. Register your first agent to get started.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Client ID</th>
|
||||
<th>Scopes</th>
|
||||
<th>Grant Types</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map(a => (
|
||||
<tr key={a.client_id} onClick={() => setSelectedAgent(a)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 500 }}>{a.client_name}</td>
|
||||
<td className="mono" style={{ color: 'var(--text-secondary)' }}>{a.client_id.substring(0, 20)}...</td>
|
||||
<td>
|
||||
{(a.scope || '').split(' ').filter(Boolean).map(s => (
|
||||
<span key={s} className={`badge badge-${s}`} style={{ marginRight: 4 }}>{s}</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="mono" style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
|
||||
{(a.grant_types || []).join(', ')}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>
|
||||
{new Date(a.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 12 }}>
|
||||
{agents.length} agent{agents.length !== 1 ? 's' : ''} registered
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showRegister && (
|
||||
<RegisterModal
|
||||
onClose={() => setShowRegister(false)}
|
||||
onRegistered={(creds) => { setShowRegister(false); setShowCredentials(creds); loadAgents(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCredentials && (
|
||||
<CredentialsModal
|
||||
credentials={showCredentials}
|
||||
onClose={() => setShowCredentials(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedAgent && (
|
||||
<AgentDrawer agent={selectedAgent} onClose={() => setSelectedAgent(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RegisterModal({ onClose, onRegistered }: {
|
||||
onClose: () => void;
|
||||
onRegistered: (creds: { clientId: string; clientSecret: string; name: string }) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [scopes, setScopes] = useState({ read: true, write: false, admin: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) { setError('Name required'); return; }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
// Use the CLI registration endpoint (POST to admin API)
|
||||
const selectedScopes = Object.entries(scopes).filter(([, v]) => v).map(([k]) => k).join(' ');
|
||||
const res = await fetch('/admin/api/register-client', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), scopes: selectedScopes }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Registration failed');
|
||||
const data = await res.json();
|
||||
onRegistered({ clientId: data.clientId, clientSecret: data.clientSecret, name: name.trim() });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal" onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
|
||||
<div className="modal-title">Register Agent</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>Agent Name</label>
|
||||
<input placeholder="e.g. perplexity-production" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label>Scopes</label>
|
||||
<div className="checkbox-group">
|
||||
{(['read', 'write', 'admin'] as const).map(s => (
|
||||
<label key={s} className="checkbox-label">
|
||||
<input type="checkbox" checked={scopes[s]} onChange={e => setScopes(p => ({ ...p, [s]: e.target.checked }))} />
|
||||
{s}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div style={{ color: 'var(--error)', fontSize: 13, marginBottom: 12 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Registering...' : 'Register'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialsModal({ credentials, onClose }: {
|
||||
credentials: { clientId: string; clientSecret: string; name: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const copy = (text: string) => navigator.clipboard.writeText(text);
|
||||
const downloadJson = () => {
|
||||
const blob = new Blob([JSON.stringify(credentials, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${credentials.name}-credentials.json`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 36, color: 'var(--success)', marginBottom: 8 }}>✓</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600 }}>Agent Registered</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12 }}>Client ID</label>
|
||||
<div className="code-block">
|
||||
<span>{credentials.clientId}</span>
|
||||
<button className="copy-btn" onClick={() => copy(credentials.clientId)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 12 }}>Client Secret</label>
|
||||
<div className="code-block">
|
||||
<span>{credentials.clientSecret}</span>
|
||||
<button className="copy-btn" onClick={() => copy(credentials.clientSecret)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="warning-bar">
|
||||
Save this secret now. It will not be shown again.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
|
||||
<button className="btn btn-secondary" onClick={downloadJson}>Download as JSON</button>
|
||||
<button className="btn btn-primary" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDrawer({ agent, onClose }: { agent: Agent; onClose: () => void }) {
|
||||
const [tab, setTab] = useState<'perplexity' | 'claude' | 'json'>('perplexity');
|
||||
const copy = (text: string) => navigator.clipboard.writeText(text);
|
||||
|
||||
const configSnippets: Record<string, string> = {
|
||||
perplexity: `URL: http://YOUR_SERVER/mcp\nClient ID: ${agent.client_id}\n\nPaste into Settings > Connectors`,
|
||||
claude: `claude mcp add gbrain \\\n -t http http://YOUR_SERVER/mcp \\\n --client-id ${agent.client_id} \\\n --client-secret YOUR_SECRET`,
|
||||
json: JSON.stringify({ client_id: agent.client_id, client_name: agent.client_name, scope: agent.scope, grant_types: agent.grant_types }, null, 2),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-overlay" onClick={onClose} />
|
||||
<div className="drawer">
|
||||
<button className="drawer-close" onClick={onClose}>✕</button>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>{agent.client_name}</div>
|
||||
<span className="badge badge-success">Active</span>
|
||||
|
||||
<div className="section-title">Details</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: '6px 12px', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Client ID</span>
|
||||
<span className="mono">{agent.client_id.substring(0, 24)}...</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Scopes</span>
|
||||
<span>{(agent.scope || '').split(' ').filter(Boolean).map(s => (
|
||||
<span key={s} className={`badge badge-${s}`} style={{ marginRight: 4 }}>{s}</span>
|
||||
))}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>Registered</span>
|
||||
<span>{new Date(agent.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="section-title">Config Export</div>
|
||||
<div className="tabs">
|
||||
<div className={`tab ${tab === 'perplexity' ? 'active' : ''}`} onClick={() => setTab('perplexity')}>Perplexity</div>
|
||||
<div className={`tab ${tab === 'claude' ? 'active' : ''}`} onClick={() => setTab('claude')}>Claude Code</div>
|
||||
<div className={`tab ${tab === 'json' ? 'active' : ''}`} onClick={() => setTab('json')}>JSON</div>
|
||||
</div>
|
||||
<div className="code-block">
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{configSnippets[tab]}</pre>
|
||||
<button className="copy-btn" onClick={() => copy(configSnippets[tab])}>Copy</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<button className="btn btn-danger">Revoke Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
interface FeedEvent {
|
||||
agent: string;
|
||||
operation: string;
|
||||
scopes: string;
|
||||
latency_ms: number;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [stats, setStats] = useState({ connected_agents: 0, requests_today: 0, active_tokens: 0 });
|
||||
const [health, setHealth] = useState({ expiring_soon: 0, error_rate: '0%' });
|
||||
const [events, setEvents] = useState<FeedEvent[]>([]);
|
||||
const [sseStatus, setSseStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.stats().then(setStats).catch(() => {});
|
||||
api.health().then(setHealth).catch(() => {});
|
||||
|
||||
const es = new EventSource('/admin/events');
|
||||
eventSourceRef.current = es;
|
||||
es.onopen = () => setSseStatus('connected');
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data) as FeedEvent;
|
||||
setEvents(prev => [event, ...prev].slice(0, 50));
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
setSseStatus('disconnected');
|
||||
setTimeout(() => {
|
||||
setSseStatus('connecting');
|
||||
es.close();
|
||||
// Reconnect handled by browser EventSource auto-retry
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
api.stats().then(setStats).catch(() => {});
|
||||
api.health().then(setHealth).catch(() => {});
|
||||
}, 30000);
|
||||
|
||||
return () => { es.close(); clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
const timeAgo = (ts: string) => {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} min ago`;
|
||||
return `${Math.floor(diff / 3600000)}h ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="metrics">
|
||||
<div className="metric">
|
||||
<div className="metric-value">{stats.connected_agents}</div>
|
||||
<div className="metric-label">Connected Agents</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="metric-value">{stats.requests_today}</div>
|
||||
<div className="metric-label">Requests Today</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="metric-value">{stats.active_tokens}</div>
|
||||
<div className="metric-label">Active Tokens</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="section-title">
|
||||
Live Activity
|
||||
<span style={{ marginLeft: 8, fontSize: 10, color: sseStatus === 'connected' ? 'var(--success)' : sseStatus === 'connecting' ? 'var(--warning)' : 'var(--error)' }}>
|
||||
{sseStatus === 'connected' ? '● connected' : sseStatus === 'connecting' ? '● connecting...' : '● disconnected'}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div className="feed">
|
||||
{events.length === 0 ? (
|
||||
<div className="feed-empty">
|
||||
{sseStatus === 'connected' ? 'No requests yet. Agents will appear when they connect.' : 'Connecting...'}
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Operation</th>
|
||||
<th>Scopes</th>
|
||||
<th>Latency</th>
|
||||
<th>Status</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td className="mono">{e.agent}</td>
|
||||
<td className="mono">{e.operation}</td>
|
||||
<td>{e.scopes.split(',').map(s => (
|
||||
<span key={s} className={`badge badge-${s.trim()}`} style={{ marginRight: 4 }}>{s.trim()}</span>
|
||||
))}</td>
|
||||
<td className="mono">{e.latency_ms} ms</td>
|
||||
<td><span className={`badge badge-${e.status}`}>{e.status}</span></td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>{timeAgo(e.timestamp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 220 }}>
|
||||
<h2 className="section-title">Token Health</h2>
|
||||
<div className="health-panel">
|
||||
<div className="health-row">
|
||||
<span style={{ color: 'var(--warning)' }}>Expiring Soon</span>
|
||||
<span className="mono">{health.expiring_soon}</span>
|
||||
</div>
|
||||
<div className="health-row">
|
||||
<span style={{ color: 'var(--error)' }}>Error Rate</span>
|
||||
<span className="mono">{health.error_rate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
const [token, setToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.login(token);
|
||||
onLogin();
|
||||
} catch (err) {
|
||||
setError('Invalid token. Check your terminal output.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-box" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">GBrain</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Admin Token"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Authenticating...' : 'Submit'}
|
||||
</button>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<div className="login-hint">Find this token in your terminal output.</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
token_name: string;
|
||||
operation: string;
|
||||
latency_ms: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function RequestLogPage() {
|
||||
const [data, setData] = useState<{ rows: LogEntry[]; total: number; page: number; pages: number }>({
|
||||
rows: [], total: 0, page: 1, pages: 1,
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => { loadPage(page); }, [page]);
|
||||
|
||||
const loadPage = (p: number) => {
|
||||
api.requests(p).then(setData).catch(() => {});
|
||||
};
|
||||
|
||||
const timeAgo = (ts: string) => {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`;
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} min ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Request Log</h1>
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--text-muted)' }}>
|
||||
No requests yet.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Agent</th>
|
||||
<th>Operation</th>
|
||||
<th>Latency</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.rows.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>{timeAgo(r.created_at)}</td>
|
||||
<td className="mono">{r.token_name || 'unknown'}</td>
|
||||
<td className="mono">{r.operation}</td>
|
||||
<td className="mono">{r.latency_ms} ms</td>
|
||||
<td><span className={`badge badge-${r.status}`}>{r.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pagination">
|
||||
<span>Page {data.page} of {data.pages} ({data.total} total)</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button disabled={data.page <= 1} onClick={() => setPage(p => p - 1)}>Previous</button>
|
||||
<button disabled={data.page >= data.pages} onClick={() => setPage(p => p + 1)}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/admin/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -9,7 +9,11 @@
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@dqbd/tiktoken": "^1.0.22",
|
||||
"@electric-sql/pglite": "0.4.3",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^18.0.0",
|
||||
"openai": "^4.0.0",
|
||||
@@ -20,6 +24,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"bun-types": "^1.3.13",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
@@ -219,12 +226,34 @@
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/cookie-parser": ["@types/cookie-parser@1.4.10", "", { "peerDependencies": { "@types/express": "*" } }, "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg=="],
|
||||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
|
||||
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.15.0", "", {}, "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
@@ -259,7 +288,9 @@
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
"cookie-parser": ["cookie-parser@1.4.7", "", { "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.6" } }, "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
@@ -299,7 +330,7 @@
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
@@ -497,8 +528,12 @@
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="],
|
||||
|
||||
"@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Brains and Sources — the mental model
|
||||
|
||||
GBrain has two orthogonal axes for organizing knowledge. Users and agents both
|
||||
need to understand both of them, or queries misroute silently.
|
||||
|
||||
**TL;DR:**
|
||||
- A **brain** is a database. You can have many.
|
||||
- A **source** is a named repo of content *inside* a brain. One brain can hold many.
|
||||
- `--brain <id>` picks WHICH DATABASE.
|
||||
- `--source <id>` picks WHICH REPO WITHIN that database.
|
||||
- They're independent. You can target any combination.
|
||||
|
||||
---
|
||||
|
||||
## The two axes
|
||||
|
||||
### Brains (the DB axis)
|
||||
|
||||
A **brain** is one database — PGLite file, self-hosted Postgres, or Supabase.
|
||||
Each brain has:
|
||||
- Its own `pages` table, `chunks` table, `embeddings`, etc.
|
||||
- Its own OAuth surface if served over HTTP MCP (v0.19+, PR 2).
|
||||
- Its own separate lifecycle, backup, access control.
|
||||
|
||||
Brains are enumerated by:
|
||||
- **host** — your default brain, configured in `~/.gbrain/config.json`.
|
||||
- **mounts** — additional brains registered in `~/.gbrain/mounts.json` via
|
||||
`gbrain mounts add <id>` (v0.19+).
|
||||
|
||||
Routing: `--brain <id>`, `GBRAIN_BRAIN_ID`, `.gbrain-mount` dotfile, or
|
||||
longest-path match against registered mount paths. Falls back to `host`.
|
||||
|
||||
### Sources (the repo axis, v0.18.0+)
|
||||
|
||||
A **source** is a named content repo *inside* one brain. Every `pages` row
|
||||
carries a `source_id`. Slugs are unique per source, not globally.
|
||||
|
||||
Example: in one brain, the slug `topics/ai` can exist under `source=wiki`
|
||||
AND under `source=gstack` — they're different pages.
|
||||
|
||||
Routing: `--source <id>`, `GBRAIN_SOURCE`, `.gbrain-source` dotfile, or
|
||||
registered `local_path` match in the `sources` table.
|
||||
|
||||
### When does each axis move?
|
||||
|
||||
| You want to | Adjust |
|
||||
|---|---|
|
||||
| Work in a different repo within the same brain (wiki → gstack notes) | `--source` |
|
||||
| Query a team-published brain that isn't yours | `--brain` |
|
||||
| Isolate a topic so it never leaks into personal search | `--source` with `federated=false` |
|
||||
| Share a brain with teammates | `--brain` (mount the team brain) |
|
||||
| Add a new repo to your personal brain | `--source` via `gbrain sources add` |
|
||||
| Add a team brain | `--brain` via `gbrain mounts add` |
|
||||
|
||||
**Rule of thumb:** if the data owner changes, it's a brain boundary. If the
|
||||
data owner stays the same but the topic/repo changes, it's a source boundary.
|
||||
|
||||
---
|
||||
|
||||
## Topology: a single-person developer
|
||||
|
||||
Simplest case. One brain, one source.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ host brain (~/.gbrain) │
|
||||
│ ├── source: default (federated=true) │
|
||||
│ │ └── all pages │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`gbrain query "retry budgets"` finds everything. No `--brain`, no `--source`
|
||||
needed.
|
||||
|
||||
---
|
||||
|
||||
## Topology: a personal brain with multiple repos
|
||||
|
||||
You maintain several codebases or writing streams. Each is its own source
|
||||
inside one brain. Cross-source search is on by default so a query about
|
||||
"caching" returns hits from every repo.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ host brain (~/.gbrain) │
|
||||
│ ├── source: wiki (federated=true) │
|
||||
│ │ └── personal notes, people, companies │
|
||||
│ ├── source: gstack (federated=true) │
|
||||
│ │ └── gstack plans, learnings │
|
||||
│ ├── source: openclaw (federated=true) │
|
||||
│ │ └── openclaw docs, memos │
|
||||
│ └── source: essays (federated=false) │
|
||||
│ └── draft essays, isolated on purpose │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Inside `~/openclaw/` the `.gbrain-source` dotfile pins every command to
|
||||
`source=openclaw`. Inside `~/gstack/` the dotfile pins to `source=gstack`.
|
||||
Everything still targets one DB.
|
||||
|
||||
Use this topology when:
|
||||
- You own all the content.
|
||||
- You want cross-repo search to just work.
|
||||
- You don't need to share any of it with someone who isn't you.
|
||||
|
||||
---
|
||||
|
||||
## Topology: personal brain + one team brain
|
||||
|
||||
You're on a team that publishes a shared brain. Your personal brain stays
|
||||
as-is; you mount the team brain alongside it.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ host brain (~/.gbrain) — YOUR personal DB │
|
||||
│ ├── source: wiki │
|
||||
│ ├── source: gstack │
|
||||
│ └── ... │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ mount: media-team │
|
||||
│ path: ~/team-brains/media │
|
||||
│ engine: postgres (team's Supabase) │
|
||||
│ └── sources: wiki, raw, enriched │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`gbrain query "X"` (no flags) → runs against host (your personal brain).
|
||||
`gbrain query "X" --brain media-team` → runs against the team's DB.
|
||||
Inside `~/team-brains/media/` a `.gbrain-mount` dotfile pins brain to
|
||||
`media-team` automatically.
|
||||
|
||||
Use this topology when:
|
||||
- You're on a team and someone publishes a brain the team subscribes to.
|
||||
- You need data isolation between work and personal.
|
||||
- Different teams/orgs own different brains.
|
||||
|
||||
---
|
||||
|
||||
## Topology: a CEO-class user with multiple team memberships
|
||||
|
||||
You're senior enough to sit across multiple teams. You maintain your personal
|
||||
brain (with N sources inside) AND mount several work team brains. Each team
|
||||
brain is itself a multi-source brain in the v0.18.0 sense — organized
|
||||
internally however the team owner chose.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ host brain — YOUR personal DB │
|
||||
│ ├── source: wiki │
|
||||
│ ├── source: essays │
|
||||
│ ├── source: gstack │
|
||||
│ └── source: openclaw │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ mount: media-team (your media team's brain) │
|
||||
│ └── sources: wiki, pipeline, enriched │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ mount: policy-team (your policy team's) │
|
||||
│ └── sources: wiki, research, letters │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ mount: portfolio (another team's) │
|
||||
│ └── sources: companies, deals, diligence │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Inside each team's checkout, a `.gbrain-mount` dotfile pins the brain. Inside
|
||||
a specific subdirectory, a `.gbrain-source` dotfile pins the source. So `cd
|
||||
~/team-brains/policy/research && gbrain query "X"` targets
|
||||
`brain=policy-team, source=research` with zero flags.
|
||||
|
||||
Use this topology when:
|
||||
- You cross-cut multiple teams.
|
||||
- Each team owns its own brain with its own access policy.
|
||||
- You need latent-space federation (agent decides when to query across
|
||||
brains), not SQL federation.
|
||||
|
||||
Cross-brain queries are **not deterministic** in v0.19. The agent sees the
|
||||
brain list and re-queries as needed. That's the feature — it keeps debugging
|
||||
sane and access control clean.
|
||||
|
||||
---
|
||||
|
||||
## Resolution precedence (one page to remember)
|
||||
|
||||
```
|
||||
WHICH BRAIN (DB)? WHICH SOURCE (repo in DB)?
|
||||
1. --brain <id> 1. --source <id>
|
||||
2. GBRAIN_BRAIN_ID env 2. GBRAIN_SOURCE env
|
||||
3. .gbrain-mount dotfile 3. .gbrain-source dotfile
|
||||
4. longest-prefix mount path match 4. longest-prefix source path match
|
||||
5. (reserved: brains.default v2) 5. sources.default config
|
||||
6. fallback: 'host' 6. fallback: 'default'
|
||||
```
|
||||
|
||||
Both axes follow the same layered pattern on purpose. If you know one, you
|
||||
know the other.
|
||||
|
||||
---
|
||||
|
||||
## For agents reading this
|
||||
|
||||
- Default assumption when the user asks a question: start in the current
|
||||
brain (resolved via the precedence above). Don't jump brains without a
|
||||
reason.
|
||||
- If the user asks a question that crosses topic areas a team might own
|
||||
(e.g. "what did Team X decide last week?"), the right move is to *query
|
||||
the team's brain explicitly* rather than searching host with "team x".
|
||||
- Cross-brain federation is YOUR JOB, not the DB's. You have the brain list
|
||||
(`gbrain mounts list`). You decide when to fan out. You synthesize
|
||||
findings. You cite `brain:source:slug`.
|
||||
- When writing a page, respect the brain boundary. A fact about a team's
|
||||
work belongs in the team's brain, not in the user's personal brain. Ask
|
||||
before writing cross-brain.
|
||||
- See `skills/conventions/brain-routing.md` for the full decision table.
|
||||
|
||||
## For users reading this
|
||||
|
||||
- **Default path:** set up your personal brain (`gbrain init`), add a source
|
||||
per repo you care about (`gbrain sources add gstack --path ~/gstack`).
|
||||
You'll almost never need `--brain`.
|
||||
- **When a team publishes a brain:** `gbrain mounts add <team-id> --path
|
||||
<clone> --db-url <url>` and the `.gbrain-mount` dotfile in that checkout
|
||||
routes queries there automatically.
|
||||
- **When you are the CEO-class user with multiple team memberships:** mount
|
||||
each team brain. Trust the resolver — inside a team's directory the
|
||||
dotfile picks the brain, inside a subdirectory the dotfile picks the
|
||||
source. The flags are for when you want to query across the boundary
|
||||
deliberately.
|
||||
|
||||
## Further reading
|
||||
|
||||
- v0.18.0 CHANGELOG — introduced `sources` primitive.
|
||||
- v0.19.0 CHANGELOG (TBD after PR 0+1+2 ship) — introduces `mounts`.
|
||||
- `docs/mounts/publishing-a-team-brain.md` (PR 2) — how to be the brain
|
||||
publisher, not just the subscriber.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Connect GBrain to ChatGPT
|
||||
|
||||
**Status (v0.26.0):** Unblocked. GBrain's `gbrain serve --http` ships OAuth 2.1
|
||||
with PKCE, which is the ChatGPT MCP connector's hard requirement. Before v1.0,
|
||||
this was a P0 TODO — the only major AI client that could not connect.
|
||||
|
||||
ChatGPT does not support bearer-token MCP servers. You must use the OAuth 2.1
|
||||
HTTP server.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Start the HTTP server
|
||||
|
||||
```bash
|
||||
gbrain serve --http --port 3131
|
||||
```
|
||||
|
||||
Save the admin bootstrap token printed on stderr. Open
|
||||
`http://localhost:3131/admin` and paste it to access the dashboard.
|
||||
|
||||
### 2. Register a ChatGPT client
|
||||
|
||||
ChatGPT uses the authorization code flow with PKCE (browser-based OAuth).
|
||||
Register from the `/admin` dashboard:
|
||||
|
||||
1. Click **Register client**.
|
||||
2. Name: `chatgpt`.
|
||||
3. Grant type: `authorization_code`.
|
||||
4. Scopes: `read`, `write` (leave `admin` unchecked for ChatGPT).
|
||||
5. Redirect URI: ChatGPT's OAuth redirect (copy it from the ChatGPT
|
||||
connector setup screen — something like
|
||||
`https://chat.openai.com/connector_platform_oauth_redirect`).
|
||||
6. Hit **Register**. The credential-reveal modal shows the `client_id` once
|
||||
with Copy and Download JSON buttons. There is no client secret for
|
||||
PKCE-based public clients.
|
||||
|
||||
Host-repo wrappers can register programmatically:
|
||||
|
||||
```ts
|
||||
await oauthProvider.registerClientManual(
|
||||
'chatgpt',
|
||||
['authorization_code'],
|
||||
'read write',
|
||||
['https://chat.openai.com/connector_platform_oauth_redirect'],
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Expose the server publicly
|
||||
|
||||
```bash
|
||||
brew install ngrok
|
||||
ngrok http 3131 --url your-brain.ngrok.app
|
||||
```
|
||||
|
||||
Your OAuth issuer URL becomes `https://your-brain.ngrok.app`. ChatGPT's
|
||||
connector auto-discovers the spec-compliant endpoint at
|
||||
`/.well-known/oauth-authorization-server`.
|
||||
|
||||
### 4. Add the connector in ChatGPT
|
||||
|
||||
1. Open ChatGPT > Settings > Connectors.
|
||||
2. Click **Add connector**.
|
||||
3. MCP server URL: `https://your-brain.ngrok.app/mcp`.
|
||||
4. Client ID: the `client_id` you saved in step 2.
|
||||
5. Click **Connect**. ChatGPT opens the OAuth consent page, you approve, and
|
||||
the connector is live.
|
||||
|
||||
Start a new conversation and ask ChatGPT to search your brain. The MCP tool
|
||||
calls show up in the admin dashboard's live SSE feed in real time.
|
||||
|
||||
## Scopes
|
||||
|
||||
ChatGPT clients can request any combination of `read`, `write`, `admin`. The
|
||||
scopes granted at consent time are enforced on every tool call. Four
|
||||
operations are `localOnly` and rejected over HTTP regardless of scope:
|
||||
`sync_brain`, `file_upload`, `file_list`, `file_url`. The HTTP server fails
|
||||
closed for any attempt to reach local filesystem surface area.
|
||||
|
||||
Recommended ChatGPT scope: `read write`. Leave `admin` for your local CLI
|
||||
and the admin dashboard.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Invalid redirect_uri" during the ChatGPT connector OAuth handshake**
|
||||
The registered `redirect-uri` must match ChatGPT's exactly. If ChatGPT
|
||||
rejects your server, check the admin dashboard's **Agents** table for the
|
||||
client, confirm the redirect URI matches what the error page shows, and
|
||||
re-register with the correct URI.
|
||||
|
||||
**ChatGPT shows an MCP connection error after approval**
|
||||
Open `/admin`, watch the SSE feed, and try again. If no request arrives, the
|
||||
connector isn't reaching your ngrok URL. If a request arrives but fails,
|
||||
the Request Log tab shows the exact error.
|
||||
|
||||
**"Unsupported grant_type" on the token endpoint**
|
||||
ChatGPT uses `authorization_code`, which the MCP SDK supports natively.
|
||||
If you see this error, verify the client was registered with
|
||||
`--grant-types authorization_code` and not `client_credentials`.
|
||||
|
||||
## See also
|
||||
|
||||
- [DEPLOY.md](DEPLOY.md) — full OAuth 2.1 setup reference
|
||||
- [ALTERNATIVES.md](ALTERNATIVES.md) — tunnel options (ngrok, Tailscale, Fly)
|
||||
+131
-15
@@ -1,17 +1,21 @@
|
||||
# Deploy GBrain Remote MCP Server
|
||||
|
||||
> **v0.22.7+:** Use `gbrain serve --http` for remote access. It includes built-in
|
||||
> bearer token auth, default-deny CORS, two-bucket rate limiting, body cap, and
|
||||
> per-request audit log. **Postgres-only** (PGLite is local-only by design).
|
||||
> See [SECURITY.md](../../SECURITY.md) for env vars and tunable defaults.
|
||||
> **v0.26.0+:** `gbrain serve --http` ships full OAuth 2.1 (client credentials,
|
||||
> auth code + PKCE, refresh rotation, optional DCR), an embedded React admin
|
||||
> dashboard at `/admin`, scoped operations, and a live SSE activity feed.
|
||||
> Pre-v0.26 legacy bearer tokens still work — `verifyAccessToken` falls back
|
||||
> to the `access_tokens` table and grandfathers tokens to `read+write+admin`.
|
||||
> Postgres-only for the legacy fallback (the `access_tokens` table is Postgres-only);
|
||||
> OAuth tables work on both PGLite and Postgres. See [SECURITY.md](../../SECURITY.md)
|
||||
> for env vars and tunable defaults.
|
||||
|
||||
Access your brain from any device, any AI client. GBrain's MCP server runs locally
|
||||
via `gbrain serve` (stdio). For remote access, expose it via the built-in HTTP
|
||||
transport behind a public tunnel.
|
||||
Access your brain from any device, any AI client. GBrain ships two transports:
|
||||
`gbrain serve` (stdio) for local agents, and `gbrain serve --http` (v0.26.0+)
|
||||
for remote clients over OAuth 2.1.
|
||||
|
||||
## Two Paths
|
||||
## Three Paths
|
||||
|
||||
### Local (zero setup)
|
||||
### Local stdio (zero setup)
|
||||
|
||||
```bash
|
||||
gbrain serve
|
||||
@@ -20,7 +24,30 @@ gbrain serve
|
||||
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
|
||||
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
|
||||
|
||||
### Remote (any device, any AI client) — Postgres only
|
||||
### Remote over OAuth 2.1 (recommended, v0.26.0+)
|
||||
|
||||
```bash
|
||||
gbrain serve --http --port 3131
|
||||
ngrok http 3131 --url your-brain.ngrok.app
|
||||
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app
|
||||
```
|
||||
|
||||
Built-in HTTP transport with OAuth 2.1, scoped operations, an admin dashboard
|
||||
at `/admin`, and a live SSE activity feed. Zero external dependencies. This is
|
||||
the only path that works with ChatGPT (OAuth 2.1 + PKCE is required by the
|
||||
ChatGPT MCP connector). Pass `--public-url` whenever the server is reachable
|
||||
at anything other than `http://localhost:<port>` so the OAuth issuer in
|
||||
discovery metadata matches what clients hit (RFC 8414 §3.3).
|
||||
|
||||
Supported clients:
|
||||
- **ChatGPT** — requires OAuth 2.1 + PKCE. Works natively with `--http`.
|
||||
- **Claude Desktop / Cowork** — OAuth 2.1 or legacy bearer tokens.
|
||||
- **Perplexity** — OAuth 2.1 client credentials grant.
|
||||
- **Claude Code, Cursor, Windsurf** — can use OAuth or legacy bearer.
|
||||
|
||||
See the [OAuth 2.1 setup](#oauth-21-setup-v100) section below.
|
||||
|
||||
### Remote with legacy bearer tokens (pre-v0.26 deployments) — Postgres only
|
||||
|
||||
```
|
||||
Your AI client (Claude Desktop, Perplexity, etc.)
|
||||
@@ -36,7 +63,94 @@ This requires:
|
||||
3. A public tunnel (ngrok, Tailscale, or cloud host)
|
||||
4. A bearer token created via `gbrain auth create <name>`
|
||||
|
||||
## Remote Setup
|
||||
Pre-v1.0 tokens are grandfathered as `read+write+admin` scopes when you upgrade
|
||||
to the HTTP server, so no migration is required.
|
||||
|
||||
## OAuth 2.1 Setup (v0.26.0+)
|
||||
|
||||
### 1. Start the HTTP server
|
||||
|
||||
```bash
|
||||
gbrain serve --http --port 3131
|
||||
```
|
||||
|
||||
On first start, the server prints an **admin bootstrap token** to stderr:
|
||||
|
||||
```
|
||||
Admin bootstrap token: 3a1f9c...
|
||||
Open http://localhost:3131/admin and paste it to log in.
|
||||
```
|
||||
|
||||
Save this token. Open `http://localhost:3131/admin` and paste it to access the
|
||||
dashboard. The dashboard shows live activity, registered clients, request logs,
|
||||
and per-client config export.
|
||||
|
||||
### 2. Register OAuth clients
|
||||
|
||||
Register clients from the **`/admin` dashboard**:
|
||||
|
||||
1. Click **Register client**.
|
||||
2. Enter a name (e.g. `perplexity`, `chatgpt`).
|
||||
3. Pick scopes: `read`, `write`, `admin` (checkboxes).
|
||||
4. Pick grant type: `client_credentials` for machine-to-machine (Perplexity,
|
||||
Claude Desktop bearer mode) or `authorization_code` for browser-based
|
||||
clients with PKCE (ChatGPT).
|
||||
5. For `authorization_code` clients, paste the redirect URI.
|
||||
6. Hit **Register**. The credential-reveal modal shows the `client_id` (and
|
||||
`client_secret` for confidential clients) once. Copy or Download JSON
|
||||
immediately — secrets are hashed on storage and never shown again.
|
||||
|
||||
Or from the CLI — faster for scripting:
|
||||
|
||||
```bash
|
||||
gbrain auth register-client perplexity \
|
||||
--grant-types client_credentials \
|
||||
--scopes "read write"
|
||||
```
|
||||
|
||||
Host-repo wrappers can register programmatically:
|
||||
|
||||
```ts
|
||||
await oauthProvider.registerClientManual(
|
||||
'perplexity',
|
||||
['client_credentials'],
|
||||
'read write',
|
||||
[], // redirect_uris, empty for CC
|
||||
);
|
||||
```
|
||||
|
||||
For self-service client registration (Dynamic Client Registration, RFC 7591),
|
||||
start the server with `--enable-dcr`. DCR is off by default.
|
||||
|
||||
### 3. Expose the server
|
||||
|
||||
```bash
|
||||
brew install ngrok
|
||||
ngrok config add-authtoken YOUR_TOKEN
|
||||
ngrok http 3131 --url your-brain.ngrok.app
|
||||
```
|
||||
|
||||
Your OAuth issuer URL becomes `https://your-brain.ngrok.app`. The MCP SDK's
|
||||
router exposes the spec-compliant discovery endpoint at
|
||||
`/.well-known/oauth-authorization-server`.
|
||||
|
||||
### 4. Scopes and localOnly
|
||||
|
||||
Every operation is tagged `read | write | admin`. Four operations are
|
||||
`localOnly` and rejected over HTTP regardless of scope: `sync_brain`,
|
||||
`file_upload`, `file_list`, `file_url`. Remote agents cannot reach local
|
||||
filesystem surface area.
|
||||
|
||||
| Scope | What it allows |
|
||||
|-------|---------------|
|
||||
| `read` | `search`, `query`, `get_page`, `list_pages`, graph traversal |
|
||||
| `write` | `put_page`, `delete_page`, `add_link`, `add_timeline_entry` |
|
||||
| `admin` | Client management, token revocation, sweep, local-only ops |
|
||||
|
||||
## Legacy Bearer Token Setup
|
||||
|
||||
Keep using pre-v0.26 bearer tokens if you aren't ready to migrate. They
|
||||
grandfather to `read+write+admin` scopes on the HTTP server.
|
||||
|
||||
### 1. Set up the tunnel
|
||||
|
||||
@@ -67,6 +181,7 @@ if compromised. Tokens are stored SHA-256 hashed in your database.
|
||||
|
||||
### 3. Connect your AI client
|
||||
|
||||
- **ChatGPT:** [setup guide](CHATGPT.md) (OAuth 2.1 + PKCE, requires `gbrain serve --http`)
|
||||
- **Claude Code:** [setup guide](CLAUDE_CODE.md)
|
||||
- **Claude Desktop:** [setup guide](CLAUDE_DESKTOP.md) (must use GUI, not JSON config)
|
||||
- **Claude Cowork:** [setup guide](CLAUDE_COWORK.md)
|
||||
@@ -123,7 +238,8 @@ Remote servers must be added via Settings > Integrations, NOT
|
||||
| put_page | 100-500ms | Write + trigger search_vector update |
|
||||
| get_stats | < 100ms | Aggregate query |
|
||||
|
||||
**Note:** `gbrain serve --http` (built-in HTTP transport) is planned but not yet
|
||||
implemented. Currently, remote MCP requires a custom HTTP wrapper. See the
|
||||
production deployment pattern in the [voice recipe](../../recipes/twilio-voice-brain.md)
|
||||
for a reference implementation.
|
||||
**Note:** `gbrain serve --http` shipped in v0.26.0 with OAuth 2.1 + admin
|
||||
dashboard baked into the binary. The custom HTTP wrapper pattern (see
|
||||
[voice recipe](../../recipes/twilio-voice-brain.md)) is still supported for
|
||||
teams that need bespoke middleware, but for most remote deployments the
|
||||
built-in server is the recommended path.
|
||||
|
||||
+208
-28
@@ -31,7 +31,13 @@ start here.
|
||||
1. `./AGENTS.md` (this file) — install + operating protocol.
|
||||
2. [`./CLAUDE.md`](./CLAUDE.md) — architecture reference, key files, trust boundaries,
|
||||
test layout.
|
||||
3. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
|
||||
3. [`./docs/architecture/brains-and-sources.md`](./docs/architecture/brains-and-sources.md)
|
||||
— the two-axis mental model (brain = which DB, source = which repo in the DB). Every
|
||||
query routes on both axes. Read before writing anything that touches brain ops.
|
||||
4. [`./skills/conventions/brain-routing.md`](./skills/conventions/brain-routing.md) —
|
||||
agent-facing decision table: when to switch brain, when to switch source, how
|
||||
cross-brain federation works (latent-space only; the agent decides).
|
||||
5. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
|
||||
|
||||
## Trust boundary (critical)
|
||||
|
||||
@@ -98,6 +104,24 @@ suggests Supabase for 1000+ files. GStack teaches agents how to code. GBrain tea
|
||||
agents everything else: brain ops, signal detection, content ingestion, enrichment,
|
||||
cron scheduling, reports, identity, and access control.
|
||||
|
||||
## Two organizational axes (read this first)
|
||||
|
||||
GBrain knowledge is organized along two orthogonal axes. Users AND agents must
|
||||
understand both, or queries misroute silently.
|
||||
|
||||
- **Brain** — WHICH DATABASE. Your personal brain is `host`. You can mount
|
||||
additional brains (team-published, each with their own DB and access policy)
|
||||
via `gbrain mounts add` (v0.19+). Routing: `--brain`, `GBRAIN_BRAIN_ID`,
|
||||
`.gbrain-mount` dotfile.
|
||||
- **Source** — WHICH REPO INSIDE THE DATABASE. A brain can hold many sources
|
||||
(wiki, gstack, openclaw, essays). Slugs scope per source. Routing:
|
||||
`--source`, `GBRAIN_SOURCE`, `.gbrain-source` dotfile.
|
||||
|
||||
Both axes follow the same 6-tier resolution pattern. Read
|
||||
`docs/architecture/brains-and-sources.md` for topology diagrams (personal, team
|
||||
mount, CEO-class with multiple team brains) and
|
||||
`skills/conventions/brain-routing.md` for the agent-facing decision table.
|
||||
|
||||
## Architecture
|
||||
|
||||
Contract-first: `src/core/operations.ts` defines ~41 shared operations (adds `find_orphans` in v0.12.3). CLI and MCP
|
||||
@@ -113,7 +137,7 @@ strict behavior when unset.
|
||||
|
||||
## Key files
|
||||
|
||||
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `<prefix>/*` matches recursive children; bare `<prefix>` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents/<id>/...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`).
|
||||
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `<prefix>/*` matches recursive children; bare `<prefix>` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents/<id>/...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`). As of v0.26.0, every `Operation` also carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`. All ops are annotated; `sync_brain`, `file_upload`, `file_list`, and `file_url` are `admin + localOnly` (rejected over HTTP). `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs.
|
||||
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
|
||||
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
|
||||
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains.
|
||||
@@ -199,10 +223,12 @@ strict behavior when unset.
|
||||
- `src/commands/features.ts` — `gbrain features --json --auto-fix`: usage scan + feature adoption salesman
|
||||
- `src/commands/autopilot.ts` — `gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
|
||||
- `src/mcp/server.ts` — MCP stdio server (generated from operations). v0.22.7: tool-call handler delegates to `dispatchToolCall` from `src/mcp/dispatch.ts` so stdio + HTTP transports share one validation, context-build, and error-format path.
|
||||
- `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP (`http-transport.ts`). Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1 (reversed handler args) + F2 (incomplete OperationContext) + F3 (no param validation) drift bugs in the original v0.22.5 HTTP transport.
|
||||
- `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter for `gbrain serve --http`. `buildDefaultLimiters()` returns the two-bucket pipeline used by http-transport: pre-auth IP (default 30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (default 60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap (default 10K keys) bounds memory under attacker-controlled key growth; TTL prune at 2× window evicts abandoned buckets.
|
||||
- `src/mcp/http-transport.ts` (v0.22.7, rewrite) — `gbrain serve --http` HTTP transport. Postgres-only — fails fast at startup on PGLite (the `access_tokens` table only exists on Postgres). Bearer auth against SHA-256 hashes in `access_tokens`. CORS default-deny via `GBRAIN_HTTP_CORS_ORIGIN` allowlist. Body cap stream-counted (1 MiB default via `GBRAIN_HTTP_MAX_BODY_BYTES`) so chunked transfers without Content-Length still hit the cap. `last_used_at` SQL-level debounce (one UPDATE per token per 60s). Per-request audit row in `mcp_request_log` with token_name + operation + status + latency. Optional `GBRAIN_HTTP_TRUST_PROXY=1` honors `X-Forwarded-For` — only safe when bound to a private interface AND the proxy strips client-supplied XFF (otherwise enables IP spoofing past the pre-auth rate limit). `/health` does `SELECT 1` against Postgres and returns 503 + `status:unhealthy` when the DB is unreachable so orchestration doesn't see green pods while clients get misleading 401s. Replaces the standalone OAuth wrapper that was vulnerable to unauthenticated client registration.
|
||||
- `src/commands/auth.ts` — Token management for the HTTP transport. `gbrain auth create/list/revoke/test`. As of v0.22.7 wired into the main CLI (`src/cli.ts`); also runs standalone via `bun run src/commands/auth.ts ...` for environments without a compiled binary. Tokens stored as SHA-256 hashes in `access_tokens` (Postgres-only).
|
||||
- `src/mcp/dispatch.ts` (v0.22.7) — Shared tool-call dispatch consumed by both stdio (`server.ts`) and HTTP transports. Exports `dispatchToolCall(engine, name, params, opts)`, `buildOperationContext(engine, params, opts)`, and `validateParams(op, params)`. Single source of truth for `(ctx, params)` handler arg order and the 5-field `OperationContext` shape (engine + config + logger + dryRun + remote). Defaults to `remote: true` (untrusted); local CLI callers pass `remote: false`. Closed F1/F2/F3 drift bugs in the original v0.22.5 HTTP transport.
|
||||
- `src/mcp/rate-limit.ts` (v0.22.7) — Bounded-LRU token-bucket limiter. `buildDefaultLimiters()` returns the two-bucket pipeline: pre-auth IP (30/60s, fires BEFORE the DB lookup so brute-force load against `access_tokens` is actually capped) + post-auth token-id (60/60s). Tracks `lastTouchedMs` separately from `lastRefillMs` so an exhausted key can't be reset by hammering past the TTL. LRU cap bounds memory under attacker-controlled key growth.
|
||||
- `src/commands/serve-http.ts` (v0.26.0) — Express 5 HTTP MCP server with OAuth 2.1, admin dashboard, and SSE live activity feed. Started via `gbrain serve --http [--port N] [--token-ttl N] [--enable-dcr] [--public-url URL]`. Supersedes the v0.22.7 `src/mcp/http-transport.ts` simple bearer-auth path. Combines MCP SDK's `mcpAuthRouter` (authorize / token / register / revoke endpoints), a custom `client_credentials` handler (SDK's token endpoint throws `UnsupportedGrantTypeError` for CC; the custom handler runs BEFORE the router and falls through for `auth_code` / `refresh_token`), `requireBearerAuth` middleware for `/mcp` with scope enforcement before op dispatch, `localOnly` rejection, and `express-rate-limit` at 50 req / 15 min on `/token`. Serves the built admin SPA from `admin/dist/` with SPA fallback. `/admin/events` SSE endpoint broadcasts every MCP request to connected admin browsers. `cookie-parser` middleware wired (Express 5 has no built-in). Startup logging prints port, engine, configured issuer URL (honors `--public-url`), registered-client count, DCR status, and admin bootstrap token.
|
||||
- `src/core/oauth-provider.ts` (v0.26.0) — `GBrainOAuthProvider` implementing the MCP SDK's `OAuthServerProvider` + `OAuthRegisteredClientsStore` interfaces. Backed by raw SQL (works on both PGLite and Postgres — OAuth is infrastructure, not a BrainEngine concern). Full OAuth 2.1 spec: `authorize` + `exchangeAuthorizationCode` with PKCE (for ChatGPT), `client_credentials` (for Perplexity / Claude), `refresh_token` with rotation, `revokeToken`, `registerClient` (DCR path validates redirect_uri must be `https://` or loopback per RFC 6749 §3.1.2.1). All tokens + client secrets SHA-256 hashed before storage. Auth codes single-use with 10-minute TTL via atomic `DELETE...RETURNING` (closes RFC 6749 §10.5 TOCTOU race). Refresh rotation also `DELETE...RETURNING` (closes §10.4 stolen-token detection bypass). `pgArray()` escapes commas/quotes/braces in elements so a comma-bearing redirect_uri can't smuggle a second array element. Legacy `access_tokens` fallback in `verifyAccessToken` grandfathers pre-v0.26 bearer tokens as `read+write+admin`. `sweepExpiredTokens()` runs on startup wrapped in try/catch.
|
||||
- `admin/` (v0.26.0) — React 19 + Vite + TypeScript admin SPA embedded in the binary via `admin/dist/` served by `serve-http.ts`. 7 screens: Login (bootstrap token → session cookie), Dashboard (metrics + SSE feed + token health), Agents (sortable table + sparklines + Register button), Register (modal with scope checkboxes + grant type selector), Credentials reveal (full-screen modal with Copy + Download JSON + yellow one-time-only warning), Request Log (filterable paginated), Agent Detail drawer (Details / Activity / Config Export tabs + Revoke). Design tokens: `#0a0a0f` bg, Inter for UI, JetBrains Mono for data, 4-32px spacing scale, rounded pill badges. HTTP-only SameSite=Strict cookie auth. 65KB gzip. Build: `cd admin && bun install && bun run build`; output at `admin/dist/` is committed for self-contained binaries.
|
||||
- `src/commands/auth.ts` — Token management. `gbrain auth create/list/revoke/test` for legacy bearer tokens (v0.22.7 wired as a first-class CLI subcommand) plus `gbrain auth register-client` (v0.26.0) for OAuth 2.1 client registration. Legacy tokens stored as SHA-256 hashes in `access_tokens`; OAuth clients in `oauth_clients`. As of v0.26.0, legacy tokens grandfather to `read+write+admin` scopes on the OAuth HTTP server, so pre-v0.26 deployments keep working with no migration.
|
||||
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
|
||||
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
|
||||
- `src/commands/repair-jsonb.ts` — `gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
|
||||
@@ -337,6 +363,15 @@ Key commands added in v0.14.2:
|
||||
- `GBRAIN_POOL_SIZE` env var — honored by both the singleton pool (`src/core/db.ts`) and the parallel-import worker pool (`src/commands/import.ts`). Default is 10; lower to 2 for Supabase transaction pooler to avoid MaxClients crashes during `gbrain upgrade` subprocess spawns. Read at call time via `resolvePoolSize()`.
|
||||
- `gbrain doctor` gains two new checks: `sync_failures` (surfaces unacknowledged parse failures with exact paths + fix hints) and `brain_score` (renders the 5-component breakdown when score < 100: embed coverage / 35, link density / 25, timeline coverage / 15, orphans / 15, dead links / 10 — sum equals total).
|
||||
|
||||
Key commands added in v0.26.0 (OAuth 2.1 + HTTP server + admin dashboard):
|
||||
- `gbrain serve --http [--port 3131] [--token-ttl 3600] [--enable-dcr]` — HTTP MCP server with OAuth 2.1, admin dashboard at `/admin`, SSE activity feed at `/admin/events`, health check at `/health`. Prints admin bootstrap token on first start. Alongside (not replacing) stdio `gbrain serve`.
|
||||
- **OAuth client registration** — three paths:
|
||||
1. CLI: `gbrain auth register-client <name> --grant-types <types> --scopes <scopes>` (wired into `src/commands/auth.ts` as a thin wrapper over `GBrainOAuthProvider.registerClientManual`). Default grant types: `client_credentials`. Default scopes: `read`.
|
||||
2. Admin dashboard: Register client modal → credential reveal with Copy + Download JSON.
|
||||
3. SDK: `oauthProvider.registerClientManual(name, grantTypes, scopes, redirectUris)` for programmatic wrappers.
|
||||
`--enable-dcr` on `serve --http` opens the `/register` endpoint for RFC 7591 self-service registration (off by default).
|
||||
- `gbrain auth create|list|revoke|test` — legacy bearer tokens still work and grandfather to `read+write+admin` scopes on the OAuth server. `auth` is wired as a first-class `gbrain` subcommand in v0.26.0 (previously only invokable via `bun run src/commands/auth.ts`). No migration required to keep pre-v0.26 clients working.
|
||||
|
||||
Key commands added in v0.14.3 (fix wave):
|
||||
- `gbrain doctor --index-audit` — opt-in Postgres-only check reporting zero-scan indexes from `pg_stat_user_indexes`. Informational only; never auto-drops.
|
||||
- `gbrain doctor` schema_version check fails loudly when `version=0` — catches `bun install -g github:...` postinstall failures (#218) and routes users to `gbrain apply-migrations --yes`.
|
||||
@@ -418,6 +453,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
|
||||
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
|
||||
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
|
||||
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases),
|
||||
`test/oauth.test.ts` (v0.26.0 OAuth 2.1 provider — 27 cases: register, getClient, `client_credentials` grant exchange, `authorization_code` flow with PKCE challenge / verifier, refresh token rotation, `verifyAccessToken` with both OAuth + legacy `access_tokens` fallback, `revokeToken`, `sweepExpiredTokens`, and a contract test asserting `scope` + `localOnly` annotations are set correctly on all 30 operations),
|
||||
`test/check-resolvable-cli.test.ts` (v0.19 CLI wrapper: exit codes, JSON envelope shape, AGENTS.md fallback chain),
|
||||
`test/regression-v0_16_4.test.ts` (findRepoRoot regression guard — hermetic startDir parameterization),
|
||||
`test/filing-audit.test.ts` (v0.19 Check 6: `writes_pages` / `writes_to` frontmatter, filing-rules JSON validation),
|
||||
@@ -1367,6 +1403,7 @@ When multiple skills could match:
|
||||
These apply to ALL brain-writing skills:
|
||||
- `skills/conventions/quality.md` — citations, back-links, notability gate
|
||||
- `skills/conventions/brain-first.md` — check brain before external APIs
|
||||
- `skills/conventions/brain-routing.md` — which brain (DB) and which source (repo) to target; cross-brain federation is latent-space only
|
||||
- `skills/conventions/subagent-routing.md` — when to use Minions vs inline work
|
||||
- `skills/_brain-filing-rules.md` — where files go
|
||||
- `skills/_output-rules.md` — output quality standards
|
||||
@@ -1480,16 +1517,36 @@ GBrain exposes 30+ MCP tools via stdio:
|
||||
|
||||
Add to `~/.claude/server.json` (Claude Code), Settings > MCP Servers (Cursor), or your client's MCP config.
|
||||
|
||||
### Remote MCP (Claude Desktop, Cowork, Perplexity)
|
||||
### Remote MCP with OAuth 2.1 (ChatGPT, Claude Desktop, Cowork, Perplexity)
|
||||
|
||||
`gbrain serve --http` starts a production-grade OAuth 2.1 server with an embedded admin dashboard. Zero external infrastructure. Every major AI client connects, every request is scoped, every action is logged.
|
||||
|
||||
```bash
|
||||
gbrain auth create "claude-desktop" # tokens via the existing CLI
|
||||
gbrain serve --http --port 8787 # built-in HTTP transport (Postgres-only)
|
||||
ngrok http 8787 --url your-brain.ngrok.app # any tunnel works
|
||||
# Start the HTTP server (prints admin bootstrap token on first start)
|
||||
gbrain serve --http --port 3131
|
||||
|
||||
# Open the admin dashboard, paste the bootstrap token, register a client
|
||||
open http://localhost:3131/admin
|
||||
|
||||
# Expose publicly (set --public-url so the OAuth issuer matches)
|
||||
ngrok http 3131 --url your-brain.ngrok.app
|
||||
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app
|
||||
|
||||
# ChatGPT and other OAuth-aware clients can also connect:
|
||||
claude mcp add gbrain -t http https://your-brain.ngrok.app/mcp -H "Authorization: Bearer TOKEN"
|
||||
```
|
||||
|
||||
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). Hardening defaults, env vars, and threat model: [SECURITY.md](SECURITY.md). ChatGPT requires OAuth 2.1 (not yet implemented).
|
||||
Register OAuth clients from the `/admin` dashboard — click **Register client**,
|
||||
pick scopes, save the credentials shown once in the reveal modal. Programmatic
|
||||
registration via `oauthProvider.registerClientManual(...)` and the
|
||||
`gbrain auth register-client` CLI are also available.
|
||||
|
||||
- **OAuth 2.1 via the MCP SDK** — client credentials (machine-to-machine: Perplexity, Claude), authorization code + PKCE (browser-based: ChatGPT), refresh token rotation, revocation, protected resource metadata. Optional Dynamic Client Registration behind `--enable-dcr` (DCR redirect_uris must be `https://` or loopback per RFC 6749 §3.1.2.1).
|
||||
- **Scoped operations** — 30 operations tagged `read | write | admin`. `sync_brain` and `file_upload` are `localOnly`, rejected over HTTP.
|
||||
- **React admin dashboard** — 7 screens baked into the binary (~65KB gzip). Live SSE activity feed, agents table, credential reveal, filterable request log, per-client config export.
|
||||
- **Legacy bearer tokens still work** — pre-v0.26 `gbrain auth create` tokens continue to authenticate as `read+write+admin`. v0.22.7's simpler `src/mcp/http-transport.ts` path stays compiled in for backward compat callers; v0.26+ deployments use the OAuth-aware `serve-http.ts`.
|
||||
|
||||
Per-client guides: [`docs/mcp/`](docs/mcp/DEPLOY.md). Hardening defaults, env vars, and threat model: [SECURITY.md](SECURITY.md).
|
||||
|
||||
### Using gbrain with GStack
|
||||
|
||||
@@ -2108,8 +2165,15 @@ ADMIN
|
||||
gbrain doctor --locks List idle-in-tx backends (57014 diagnostic, Postgres only)
|
||||
gbrain stats Brain statistics
|
||||
gbrain serve MCP server (stdio)
|
||||
gbrain serve --http --port 8787 MCP server (HTTP, Postgres-only, bearer auth)
|
||||
gbrain auth create|list|revoke|test Token management for the HTTP transport
|
||||
gbrain serve --http [--port 3131] HTTP MCP server with OAuth 2.1 + admin dashboard
|
||||
[--token-ttl 3600] [--enable-dcr]
|
||||
[--public-url URL]
|
||||
gbrain auth create|list|revoke|test Legacy bearer token management
|
||||
gbrain auth register-client <name> Register an OAuth 2.1 client
|
||||
--grant-types client_credentials,authorization_code
|
||||
--scopes "read write admin"
|
||||
# OAuth 2.1 clients can also be registered from the /admin dashboard or
|
||||
# programmatically via oauthProvider.registerClientManual() for host-repo wrappers.
|
||||
gbrain integrations Integration recipe dashboard
|
||||
gbrain sources list|add|remove|... Multi-source brain management (v0.18)
|
||||
gbrain dream [--dry-run] [--phase N] 8-phase maintenance cycle (lint→backlinks→sync→synthesize
|
||||
@@ -4296,18 +4360,22 @@ Source: https://raw.githubusercontent.com/garrytan/gbrain/master/docs/mcp/DEPLOY
|
||||
|
||||
# Deploy GBrain Remote MCP Server
|
||||
|
||||
> **v0.22.7+:** Use `gbrain serve --http` for remote access. It includes built-in
|
||||
> bearer token auth, default-deny CORS, two-bucket rate limiting, body cap, and
|
||||
> per-request audit log. **Postgres-only** (PGLite is local-only by design).
|
||||
> See [SECURITY.md](../../SECURITY.md) for env vars and tunable defaults.
|
||||
> **v0.26.0+:** `gbrain serve --http` ships full OAuth 2.1 (client credentials,
|
||||
> auth code + PKCE, refresh rotation, optional DCR), an embedded React admin
|
||||
> dashboard at `/admin`, scoped operations, and a live SSE activity feed.
|
||||
> Pre-v0.26 legacy bearer tokens still work — `verifyAccessToken` falls back
|
||||
> to the `access_tokens` table and grandfathers tokens to `read+write+admin`.
|
||||
> Postgres-only for the legacy fallback (the `access_tokens` table is Postgres-only);
|
||||
> OAuth tables work on both PGLite and Postgres. See [SECURITY.md](../../SECURITY.md)
|
||||
> for env vars and tunable defaults.
|
||||
|
||||
Access your brain from any device, any AI client. GBrain's MCP server runs locally
|
||||
via `gbrain serve` (stdio). For remote access, expose it via the built-in HTTP
|
||||
transport behind a public tunnel.
|
||||
Access your brain from any device, any AI client. GBrain ships two transports:
|
||||
`gbrain serve` (stdio) for local agents, and `gbrain serve --http` (v0.26.0+)
|
||||
for remote clients over OAuth 2.1.
|
||||
|
||||
## Two Paths
|
||||
## Three Paths
|
||||
|
||||
### Local (zero setup)
|
||||
### Local stdio (zero setup)
|
||||
|
||||
```bash
|
||||
gbrain serve
|
||||
@@ -4316,7 +4384,30 @@ gbrain serve
|
||||
Works with Claude Code, Cursor, Windsurf, and any MCP client that supports stdio.
|
||||
No server, no tunnel, no token needed. Works on both PGLite and Postgres engines.
|
||||
|
||||
### Remote (any device, any AI client) — Postgres only
|
||||
### Remote over OAuth 2.1 (recommended, v0.26.0+)
|
||||
|
||||
```bash
|
||||
gbrain serve --http --port 3131
|
||||
ngrok http 3131 --url your-brain.ngrok.app
|
||||
gbrain serve --http --port 3131 --public-url https://your-brain.ngrok.app
|
||||
```
|
||||
|
||||
Built-in HTTP transport with OAuth 2.1, scoped operations, an admin dashboard
|
||||
at `/admin`, and a live SSE activity feed. Zero external dependencies. This is
|
||||
the only path that works with ChatGPT (OAuth 2.1 + PKCE is required by the
|
||||
ChatGPT MCP connector). Pass `--public-url` whenever the server is reachable
|
||||
at anything other than `http://localhost:<port>` so the OAuth issuer in
|
||||
discovery metadata matches what clients hit (RFC 8414 §3.3).
|
||||
|
||||
Supported clients:
|
||||
- **ChatGPT** — requires OAuth 2.1 + PKCE. Works natively with `--http`.
|
||||
- **Claude Desktop / Cowork** — OAuth 2.1 or legacy bearer tokens.
|
||||
- **Perplexity** — OAuth 2.1 client credentials grant.
|
||||
- **Claude Code, Cursor, Windsurf** — can use OAuth or legacy bearer.
|
||||
|
||||
See the [OAuth 2.1 setup](#oauth-21-setup-v100) section below.
|
||||
|
||||
### Remote with legacy bearer tokens (pre-v0.26 deployments) — Postgres only
|
||||
|
||||
```
|
||||
Your AI client (Claude Desktop, Perplexity, etc.)
|
||||
@@ -4332,7 +4423,94 @@ This requires:
|
||||
3. A public tunnel (ngrok, Tailscale, or cloud host)
|
||||
4. A bearer token created via `gbrain auth create <name>`
|
||||
|
||||
## Remote Setup
|
||||
Pre-v1.0 tokens are grandfathered as `read+write+admin` scopes when you upgrade
|
||||
to the HTTP server, so no migration is required.
|
||||
|
||||
## OAuth 2.1 Setup (v0.26.0+)
|
||||
|
||||
### 1. Start the HTTP server
|
||||
|
||||
```bash
|
||||
gbrain serve --http --port 3131
|
||||
```
|
||||
|
||||
On first start, the server prints an **admin bootstrap token** to stderr:
|
||||
|
||||
```
|
||||
Admin bootstrap token: 3a1f9c...
|
||||
Open http://localhost:3131/admin and paste it to log in.
|
||||
```
|
||||
|
||||
Save this token. Open `http://localhost:3131/admin` and paste it to access the
|
||||
dashboard. The dashboard shows live activity, registered clients, request logs,
|
||||
and per-client config export.
|
||||
|
||||
### 2. Register OAuth clients
|
||||
|
||||
Register clients from the **`/admin` dashboard**:
|
||||
|
||||
1. Click **Register client**.
|
||||
2. Enter a name (e.g. `perplexity`, `chatgpt`).
|
||||
3. Pick scopes: `read`, `write`, `admin` (checkboxes).
|
||||
4. Pick grant type: `client_credentials` for machine-to-machine (Perplexity,
|
||||
Claude Desktop bearer mode) or `authorization_code` for browser-based
|
||||
clients with PKCE (ChatGPT).
|
||||
5. For `authorization_code` clients, paste the redirect URI.
|
||||
6. Hit **Register**. The credential-reveal modal shows the `client_id` (and
|
||||
`client_secret` for confidential clients) once. Copy or Download JSON
|
||||
immediately — secrets are hashed on storage and never shown again.
|
||||
|
||||
Or from the CLI — faster for scripting:
|
||||
|
||||
```bash
|
||||
gbrain auth register-client perplexity \
|
||||
--grant-types client_credentials \
|
||||
--scopes "read write"
|
||||
```
|
||||
|
||||
Host-repo wrappers can register programmatically:
|
||||
|
||||
```ts
|
||||
await oauthProvider.registerClientManual(
|
||||
'perplexity',
|
||||
['client_credentials'],
|
||||
'read write',
|
||||
[], // redirect_uris, empty for CC
|
||||
);
|
||||
```
|
||||
|
||||
For self-service client registration (Dynamic Client Registration, RFC 7591),
|
||||
start the server with `--enable-dcr`. DCR is off by default.
|
||||
|
||||
### 3. Expose the server
|
||||
|
||||
```bash
|
||||
brew install ngrok
|
||||
ngrok config add-authtoken YOUR_TOKEN
|
||||
ngrok http 3131 --url your-brain.ngrok.app
|
||||
```
|
||||
|
||||
Your OAuth issuer URL becomes `https://your-brain.ngrok.app`. The MCP SDK's
|
||||
router exposes the spec-compliant discovery endpoint at
|
||||
`/.well-known/oauth-authorization-server`.
|
||||
|
||||
### 4. Scopes and localOnly
|
||||
|
||||
Every operation is tagged `read | write | admin`. Four operations are
|
||||
`localOnly` and rejected over HTTP regardless of scope: `sync_brain`,
|
||||
`file_upload`, `file_list`, `file_url`. Remote agents cannot reach local
|
||||
filesystem surface area.
|
||||
|
||||
| Scope | What it allows |
|
||||
|-------|---------------|
|
||||
| `read` | `search`, `query`, `get_page`, `list_pages`, graph traversal |
|
||||
| `write` | `put_page`, `delete_page`, `add_link`, `add_timeline_entry` |
|
||||
| `admin` | Client management, token revocation, sweep, local-only ops |
|
||||
|
||||
## Legacy Bearer Token Setup
|
||||
|
||||
Keep using pre-v0.26 bearer tokens if you aren't ready to migrate. They
|
||||
grandfather to `read+write+admin` scopes on the HTTP server.
|
||||
|
||||
### 1. Set up the tunnel
|
||||
|
||||
@@ -4363,6 +4541,7 @@ if compromised. Tokens are stored SHA-256 hashed in your database.
|
||||
|
||||
### 3. Connect your AI client
|
||||
|
||||
- **ChatGPT:** [setup guide](CHATGPT.md) (OAuth 2.1 + PKCE, requires `gbrain serve --http`)
|
||||
- **Claude Code:** [setup guide](CLAUDE_CODE.md)
|
||||
- **Claude Desktop:** [setup guide](CLAUDE_DESKTOP.md) (must use GUI, not JSON config)
|
||||
- **Claude Cowork:** [setup guide](CLAUDE_COWORK.md)
|
||||
@@ -4419,10 +4598,11 @@ Remote servers must be added via Settings > Integrations, NOT
|
||||
| put_page | 100-500ms | Write + trigger search_vector update |
|
||||
| get_stats | < 100ms | Aggregate query |
|
||||
|
||||
**Note:** `gbrain serve --http` (built-in HTTP transport) is planned but not yet
|
||||
implemented. Currently, remote MCP requires a custom HTTP wrapper. See the
|
||||
production deployment pattern in the [voice recipe](../../recipes/twilio-voice-brain.md)
|
||||
for a reference implementation.
|
||||
**Note:** `gbrain serve --http` shipped in v0.26.0 with OAuth 2.1 + admin
|
||||
dashboard baked into the binary. The custom HTTP wrapper pattern (see
|
||||
[voice recipe](../../recipes/twilio-voice-brain.md)) is still supported for
|
||||
teams that need bespoke middleware, but for most remote deployments the
|
||||
built-in server is the recommended path.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+11
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.25.1",
|
||||
"version": "0.26.0",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
@@ -30,10 +30,11 @@
|
||||
"dev": "bun run src/cli.ts",
|
||||
"build": "bun build --compile --outfile bin/gbrain src/cli.ts",
|
||||
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
|
||||
"build:admin": "cd admin && bun run build",
|
||||
"build:schema": "bash scripts/build-schema.sh",
|
||||
"build:llms": "bun run scripts/build-llms.ts",
|
||||
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
|
||||
"test": "scripts/check-privacy.sh && scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-trailing-newline.sh && scripts/check-wasm-embedded.sh && scripts/check-exports-count.sh && bun run typecheck && bun test --timeout=60000",
|
||||
"test": "scripts/check-privacy.sh && scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && scripts/check-no-legacy-getconnection.sh && scripts/check-trailing-newline.sh && scripts/check-wasm-embedded.sh && scripts/check-exports-count.sh && bun run typecheck && bun test --timeout=60000",
|
||||
"check:wasm": "scripts/check-wasm-embedded.sh",
|
||||
"check:newlines": "scripts/check-trailing-newline.sh",
|
||||
"test:e2e": "bash scripts/run-e2e.sh",
|
||||
@@ -61,7 +62,11 @@
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@dqbd/tiktoken": "^1.0.22",
|
||||
"@electric-sql/pglite": "0.4.3",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^18.0.0",
|
||||
"openai": "^4.0.0",
|
||||
@@ -72,6 +77,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"bun-types": "^1.3.13",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# CI guard against silent singleton reuse in connected-gbrains code paths.
|
||||
#
|
||||
# Codex finding #7 (plan review 2026-04-22): the module singleton in
|
||||
# src/core/db.ts is shared across the process. With multi-brain routing,
|
||||
# any `db.getConnection()` call in an op-dispatch code path means that op
|
||||
# silently targets whichever brain connected to the singleton first,
|
||||
# regardless of ctx.brainId / ctx.engine. This is exactly the bug Codex
|
||||
# #1 flagged in postgres-engine.ts internals.
|
||||
#
|
||||
# This script fails the build when NEW `db.getConnection()` calls appear
|
||||
# in src/core/operations.ts (the per-op handler surface) or in any new
|
||||
# `src/commands/*.ts` file. Existing legitimate callers are grandfathered
|
||||
# via an explicit allowlist — cleanups land in PR 1.
|
||||
#
|
||||
# When you hit this guard: instead of `db.getConnection()` or `db.connect(...)`,
|
||||
# use `ctx.engine` from the passed-in OperationContext. See
|
||||
# src/core/brain-registry.ts for how ctx.engine gets populated per-call.
|
||||
#
|
||||
# Run manually: bash scripts/check-no-legacy-getconnection.sh
|
||||
# Wired into CI: `bun test` (via package.json scripts.test)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
# Files that are allowed to touch the singleton today. Every other file
|
||||
# under src/core or src/commands is forbidden. This list shrinks in PR 1.
|
||||
ALLOWED=(
|
||||
"src/core/db.ts" # the singleton's definition
|
||||
"src/core/postgres-engine.ts" # calls db.connect + fallback in sql getter — PR 1 removes the fallback
|
||||
"src/commands/init.ts" # first-time setup path, no engine yet
|
||||
"src/commands/doctor.ts" # PR 1 refactors to accept engine
|
||||
"src/commands/files.ts" # PR 1 refactors to accept engine
|
||||
"src/commands/repair-jsonb.ts" # PR 1 refactors
|
||||
"src/commands/serve-http.ts" # PR 1 threads engine through the OAuth dispatch path
|
||||
"src/core/operations.ts" # 3 localOnly ops (file_list/upload/url) move to ctx.engine in PR 1
|
||||
)
|
||||
|
||||
# Build an argument list for `grep` that excludes allowed files.
|
||||
EXCLUDE_ARGS=()
|
||||
for file in "${ALLOWED[@]}"; do
|
||||
EXCLUDE_ARGS+=(--exclude="$file")
|
||||
done
|
||||
|
||||
# Search src/core/ and src/commands/ for db.getConnection or db.connect calls.
|
||||
# We look for the `db.` prefix so references to the symbol elsewhere (e.g.
|
||||
# the grep guard itself) don't trip the check.
|
||||
VIOLATIONS=$(
|
||||
grep -rn "db\.\(getConnection\|connect\)(" \
|
||||
--include="*.ts" \
|
||||
"${EXCLUDE_ARGS[@]}" \
|
||||
src/core src/commands 2>/dev/null \
|
||||
| grep -v -F "src/core/db.ts" \
|
||||
| grep -v "^[^:]*:[0-9]*:[[:space:]]*\(//\|\*\)" \
|
||||
|| true
|
||||
)
|
||||
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
# Filter out allowed files from the result (the --exclude only matches basename)
|
||||
FILTERED=$(printf '%s\n' "$VIOLATIONS" | while IFS= read -r line; do
|
||||
path="${line%%:*}"
|
||||
allow=0
|
||||
for ok in "${ALLOWED[@]}"; do
|
||||
if [ "$path" = "$ok" ]; then allow=1; break; fi
|
||||
done
|
||||
if [ "$allow" -eq 0 ]; then printf '%s\n' "$line"; fi
|
||||
done)
|
||||
|
||||
if [ -n "$FILTERED" ]; then
|
||||
echo "ERROR: new direct db.getConnection() / db.connect() call found in multi-brain code path:" >&2
|
||||
echo "" >&2
|
||||
printf '%s\n' "$FILTERED" >&2
|
||||
echo "" >&2
|
||||
echo "Use ctx.engine from the passed-in OperationContext instead." >&2
|
||||
echo "See src/core/brain-registry.ts for the routing model." >&2
|
||||
echo "If this call is legitimate, add its path to the ALLOWED list in" >&2
|
||||
echo "scripts/check-no-legacy-getconnection.sh with a PR 1 cleanup note." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "check-no-legacy-getconnection: ok (no new singleton callers)"
|
||||
@@ -100,6 +100,7 @@ When multiple skills could match:
|
||||
These apply to ALL brain-writing skills:
|
||||
- `skills/conventions/quality.md` — citations, back-links, notability gate
|
||||
- `skills/conventions/brain-first.md` — check brain before external APIs
|
||||
- `skills/conventions/brain-routing.md` — which brain (DB) and which source (repo) to target; cross-brain federation is latent-space only
|
||||
- `skills/conventions/subagent-routing.md` — when to use Minions vs inline work
|
||||
- `skills/_brain-filing-rules.md` — where files go
|
||||
- `skills/_output-rules.md` — output quality standards
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Brain Routing Convention
|
||||
|
||||
Cross-cutting rules for which brain and which source an operation targets.
|
||||
Applies to every skill that reads or writes brain pages. **Full mental model
|
||||
lives in `docs/architecture/brains-and-sources.md` — read it once.**
|
||||
|
||||
## The two axes (one-line summary)
|
||||
|
||||
- **Brain** = which DATABASE. `--brain`, `GBRAIN_BRAIN_ID`, `.gbrain-mount`.
|
||||
- **Source** = which REPO INSIDE the database. `--source`, `GBRAIN_SOURCE`,
|
||||
`.gbrain-source`.
|
||||
|
||||
Orthogonal. Pick one on each axis per operation.
|
||||
|
||||
## Default behavior (ALWAYS)
|
||||
|
||||
Start in the brain + source resolved by the environment:
|
||||
|
||||
1. Run `gbrain mounts list` if you haven't seen the user's mounts yet.
|
||||
2. Trust the resolver. If the user is in `~/team-brains/media/`, their
|
||||
`.gbrain-mount` pins brain=media-team. Don't override that silently.
|
||||
3. For every brain op, pass the resolved brain id explicitly when calling
|
||||
tools (even if it matches the default). Makes routing visible in logs.
|
||||
|
||||
Bare `gbrain query "X"` routes to the default brain's default source. That
|
||||
is the right answer 90% of the time. Don't cross the boundary without a
|
||||
reason.
|
||||
|
||||
## When to switch brain
|
||||
|
||||
Switch brain (`--brain <id>`) when:
|
||||
|
||||
- The user's question is specifically about a team the user belongs to
|
||||
("what did team X decide?", "what's the status of project Y at team X?").
|
||||
Switch BEFORE searching, not after a failed search in host.
|
||||
- The user is asking you to ingest data that belongs to a specific team
|
||||
(meeting notes from a team meeting, letters from a team's pipeline). The
|
||||
data owner determines the brain.
|
||||
- The user explicitly names a team/brain ("check the media-team brain
|
||||
for...").
|
||||
|
||||
Do NOT switch brain when:
|
||||
|
||||
- The user asks a general question that might pull from anywhere. Start in
|
||||
host, then cross-query on-demand if host doesn't have it.
|
||||
- You're unsure. Stay in host, surface what you found, let the user point
|
||||
you at a specific brain.
|
||||
|
||||
## When to switch source
|
||||
|
||||
Switch source (`--source <id>`) when:
|
||||
|
||||
- The user is working in a specific repo (the `.gbrain-source` dotfile
|
||||
usually handles this — don't fight it).
|
||||
- The user asks about something scoped to a repo ("what's in my gstack
|
||||
notes about retry policy?").
|
||||
- You're writing a page that logically belongs to one repo. The data
|
||||
origin determines the source.
|
||||
|
||||
Do NOT switch source when:
|
||||
|
||||
- The user's intent crosses repos. Keep `federated=true` sources for
|
||||
cross-source search.
|
||||
- You'd lose a cross-repo match by isolating.
|
||||
|
||||
## Cross-brain queries (latent-space federation)
|
||||
|
||||
v0.19 does NOT do deterministic cross-brain federation. No SQL fan-out. No
|
||||
unified ranking. The AGENT federates.
|
||||
|
||||
Pattern when the user asks something that might span brains:
|
||||
|
||||
1. Query host with the obvious query.
|
||||
2. Check `gbrain mounts list` for relevant brain ids.
|
||||
3. If you think another brain has the answer, re-query THAT brain
|
||||
explicitly (`--brain <id>`).
|
||||
4. Synthesize across results. Cite `<brain>:<source>:<slug>` so the user
|
||||
can trace.
|
||||
|
||||
Never silently mix brains. Every finding is citable to its brain.
|
||||
|
||||
## Writing across brains
|
||||
|
||||
Writing is stricter than reading. ASK before writing cross-brain.
|
||||
|
||||
- A fact about a team's work → team's brain, not host.
|
||||
- A fact the user confirmed about a person ONLY they know → host/personal,
|
||||
not a team brain.
|
||||
- An enrichment discovered from public data → usually host unless the user
|
||||
says otherwise.
|
||||
|
||||
If you're about to `put_page --brain <team-brain>`, confirm with the user
|
||||
unless they explicitly said "save this to team-X". Default brain for
|
||||
writes is the user's personal brain.
|
||||
|
||||
## Citations with brain context
|
||||
|
||||
Standard citation format stays the same (`[Source: ...]`), but when pages
|
||||
come from a mounted brain, add the brain context for human traceability:
|
||||
|
||||
- Single-brain query: `[Source: Meeting, 2026-04-10]` (unchanged).
|
||||
- Cross-brain synthesis: `[Source: media-team:meetings/2026-04-10]` or
|
||||
`[Source: policy-team:research/retry-budgets]`.
|
||||
|
||||
This matches v0.18.0's source-aware citation (`[source-id:slug]`) extended
|
||||
with a brain prefix when relevant.
|
||||
|
||||
## Decision table
|
||||
|
||||
| Situation | Brain | Source |
|
||||
|---|---|---|
|
||||
| User cd's into a team-brain checkout and asks a general question | dotfile-resolved team brain | dotfile-resolved source |
|
||||
| User asks "what did team X decide?" | `team-x` explicitly | resolver default |
|
||||
| User asks "what are we doing across all teams?" | fan out across mounts, agent-driven | resolver default |
|
||||
| User asks "add this to my gstack notes" | host | `gstack` |
|
||||
| User asks "save this meeting note for team X" | `team-x` (confirm if ambiguous) | team's meetings source |
|
||||
| User asks "write me an essay" | host (personal) | `essays` |
|
||||
| Unknown — can't classify | stay in host, ask the user | resolver default |
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Silently jumping brains to "find" an answer when the user clearly meant
|
||||
host. That's an audit-trail hole.
|
||||
- Writing to host when the data is clearly team-owned ("the team's plans
|
||||
are now in your personal brain" = bad surprise).
|
||||
- Cross-brain federation in a single query without citations that name the
|
||||
source brain. The user cannot trace the answer back.
|
||||
- Ignoring `.gbrain-mount` / `.gbrain-source` dotfiles. They're load-bearing
|
||||
context — the user set them up for a reason.
|
||||
|
||||
## Read more
|
||||
|
||||
- `docs/architecture/brains-and-sources.md` — the full mental model with
|
||||
topology diagrams (single-person, personal-with-repos, CEO-class with
|
||||
multiple team brains).
|
||||
- `skills/conventions/brain-first.md` — reads the brain BEFORE asking.
|
||||
- `skills/conventions/quality.md` — citation format (extended here with
|
||||
brain prefix).
|
||||
+17
-1
@@ -19,7 +19,7 @@ for (const op of operations) {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror']);
|
||||
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror']);
|
||||
|
||||
async function main() {
|
||||
// Parse global flags (--quiet / --progress-json / --progress-interval)
|
||||
@@ -265,6 +265,11 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runInit(args);
|
||||
return;
|
||||
}
|
||||
if (command === 'auth') {
|
||||
const { runAuth } = await import('./commands/auth.ts');
|
||||
await runAuth(args);
|
||||
return;
|
||||
}
|
||||
if (command === 'upgrade') {
|
||||
const { runUpgrade } = await import('./commands/upgrade.ts');
|
||||
await runUpgrade(args);
|
||||
@@ -325,6 +330,13 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runCheckResolvable(args);
|
||||
return;
|
||||
}
|
||||
if (command === 'mounts') {
|
||||
// No DB needed: mounts.json is a local config file. Registry will
|
||||
// connect mount engines lazily on first use by op dispatch.
|
||||
const { runMounts } = await import('./commands/mounts.ts');
|
||||
await runMounts(args);
|
||||
return;
|
||||
}
|
||||
if (command === 'routing-eval') {
|
||||
const { runRoutingEvalCli } = await import('./commands/routing-eval.ts');
|
||||
await runRoutingEvalCli(args);
|
||||
@@ -749,6 +761,10 @@ ADMIN
|
||||
storage status [--repo <path>] Storage tier status and health
|
||||
[--json] (git-tracked vs supabase-only)
|
||||
serve MCP server (stdio)
|
||||
serve --http [--port N] HTTP MCP server with OAuth 2.1
|
||||
--token-ttl N Access token TTL in seconds (default: 3600)
|
||||
--enable-dcr Enable Dynamic Client Registration
|
||||
--public-url URL Public issuer URL (required behind proxy/tunnel)
|
||||
call <tool> '<json>' Raw tool invocation
|
||||
version Version info
|
||||
--tools-json Tool discovery (JSON)
|
||||
|
||||
+39
-4
@@ -225,6 +225,37 @@ async function test(url: string, token: string) {
|
||||
console.log(`\n🧠 Your brain is live! (${elapsed}s)`);
|
||||
}
|
||||
|
||||
async function registerClient(name: string, args: string[]) {
|
||||
if (!name) { console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S]'); process.exit(1); }
|
||||
const grantsIdx = args.indexOf('--grant-types');
|
||||
const scopesIdx = args.indexOf('--scopes');
|
||||
const grantTypes = grantsIdx >= 0 && args[grantsIdx + 1]
|
||||
? args[grantsIdx + 1].split(',').map(s => s.trim()).filter(Boolean)
|
||||
: ['client_credentials'];
|
||||
const scopes = scopesIdx >= 0 && args[scopesIdx + 1] ? args[scopesIdx + 1] : 'read';
|
||||
|
||||
const sql = postgres(getDatabaseUrl(true)!);
|
||||
try {
|
||||
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
|
||||
const provider = new GBrainOAuthProvider({ sql: sql as any });
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
name, grantTypes, scopes, [],
|
||||
);
|
||||
console.log(`OAuth client registered: "${name}"\n`);
|
||||
console.log(` Client ID: ${clientId}`);
|
||||
console.log(` Client Secret: ${clientSecret}\n`);
|
||||
console.log(` Grant types: ${grantTypes.join(', ')}`);
|
||||
console.log(` Scopes: ${scopes}\n`);
|
||||
console.log('Save the client secret — it will not be shown again.');
|
||||
console.log(`Revoke with: gbrain auth revoke-client "${clientId}"`);
|
||||
} catch (e: any) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the `gbrain auth` CLI subcommand. Also reused by the
|
||||
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
|
||||
@@ -236,6 +267,7 @@ export async function runAuth(args: string[]): Promise<void> {
|
||||
case 'create': await create(rest[0]); return;
|
||||
case 'list': await list(); return;
|
||||
case 'revoke': await revoke(rest[0]); return;
|
||||
case 'register-client': await registerClient(rest[0], rest.slice(1)); return;
|
||||
case 'test': {
|
||||
const tokenIdx = rest.indexOf('--token');
|
||||
const url = rest.find(a => !a.startsWith('--') && a !== rest[tokenIdx + 1]);
|
||||
@@ -247,10 +279,13 @@ export async function runAuth(args: string[]): Promise<void> {
|
||||
console.log(`GBrain Token Management
|
||||
|
||||
Usage:
|
||||
gbrain auth create <name> Create a new access token
|
||||
gbrain auth list List all tokens
|
||||
gbrain auth revoke <name> Revoke a token
|
||||
gbrain auth test <url> --token <t> Smoke-test a remote MCP server
|
||||
gbrain auth create <name> Create a legacy bearer token
|
||||
gbrain auth list List all tokens
|
||||
gbrain auth revoke <name> Revoke a legacy token
|
||||
gbrain auth register-client <name> [options] Register an OAuth 2.1 client
|
||||
--grant-types <client_credentials,authorization_code> (default: client_credentials)
|
||||
--scopes "<read write admin>" (default: read)
|
||||
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* gbrain mounts — manage connected gbrains (v0.19.0, PR 0).
|
||||
*
|
||||
* A "mount" is a SEPARATE gbrain DATABASE connected to your host agent.
|
||||
* Your host OpenClaw can mount N team-published brains (YC Media, YC
|
||||
* Politics, Garry's List) and route operations to each via `--brain <id>`.
|
||||
*
|
||||
* Mounts are distinct from v0.18.0 "sources" (repos within ONE brain).
|
||||
* Orthogonal axes:
|
||||
* --brain yc-media → which DATABASE to target
|
||||
* --source meetings → which repo WITHIN that database
|
||||
*
|
||||
* Subcommands (PR 0 — direct transport only):
|
||||
* gbrain mounts add <id> --path <path> --engine pglite|postgres [--db-url|--db-path]
|
||||
* gbrain mounts list [--json]
|
||||
* gbrain mounts remove <id>
|
||||
*
|
||||
* Not yet shipped (PR 1+):
|
||||
* gbrain mounts pin <id> <sha> — freeze at a tested version (PR 1)
|
||||
* gbrain mounts sync [--id <id>] — git pull + cache refresh (PR 1)
|
||||
* gbrain mounts enable/disable <id> — toggle without removing (PR 1)
|
||||
* gbrain mounts add --mcp-url — HTTP MCP transport + OAuth (PR 2)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, renameSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import {
|
||||
loadMounts,
|
||||
validateMountId,
|
||||
HOST_BRAIN_ID,
|
||||
DuplicateMountPathError,
|
||||
type MountEntry,
|
||||
type MountsFile,
|
||||
} from '../core/brain-registry.ts';
|
||||
import { findRepoRoot } from '../core/repo-root.ts';
|
||||
import { writeMountsCache, clearMountsCache } from '../core/mounts-cache.ts';
|
||||
import { GBrainError } from '../core/types.ts';
|
||||
|
||||
function getMountsDir(): string { return join(homedir(), '.gbrain'); }
|
||||
function getMountsPath(): string { return join(getMountsDir(), 'mounts.json'); }
|
||||
|
||||
/**
|
||||
* Read mounts.json and return the parsed MountsFile, or a fresh empty file
|
||||
* shape if the file is absent. Throws on corruption (never returns partial).
|
||||
*/
|
||||
function readMountsFile(path: string = getMountsPath()): MountsFile {
|
||||
if (!existsSync(path)) return { version: 1, mounts: [] };
|
||||
const entries = loadMounts(path);
|
||||
return { version: 1, mounts: entries };
|
||||
}
|
||||
|
||||
/** Write mounts.json atomically with 0600 perms (contains no secrets, but
|
||||
* is per-user config alongside config.json which IS secret-bearing).
|
||||
*
|
||||
* Unique tmp filename per call (pid + random). Two concurrent `gbrain
|
||||
* mounts add` invocations would otherwise clobber each other's `.tmp` file
|
||||
* and one writer's update would be lost. Unique tmp names make each
|
||||
* writer's atomic rename self-contained — last rename wins (read-modify-
|
||||
* write lost-update is a separate concern that a true file lock would
|
||||
* address, deferred to PR 1 under `gbrain mounts sync --lock`). */
|
||||
function writeMountsFile(file: MountsFile, path: string = getMountsPath()): void {
|
||||
mkdirSync(getMountsDir(), { recursive: true });
|
||||
const tmpPath = `${path}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
||||
writeFileSync(tmpPath, JSON.stringify(file, null, 2) + '\n', { mode: 0o600 });
|
||||
try { chmodSync(tmpPath, 0o600); } catch { /* platform dep */ }
|
||||
// Atomic rename so readers never see a torn file.
|
||||
renameSync(tmpPath, path);
|
||||
}
|
||||
|
||||
// ── Argument parsing helpers ───────────────────────────────────────────
|
||||
|
||||
interface AddArgs {
|
||||
id: string;
|
||||
path: string;
|
||||
engine: 'postgres' | 'pglite';
|
||||
database_url?: string;
|
||||
database_path?: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
function parseAddArgs(args: string[]): AddArgs {
|
||||
if (args.length === 0) {
|
||||
throw new GBrainError(
|
||||
'Missing mount id',
|
||||
'gbrain mounts add <id> --path <path> [flags]',
|
||||
'Provide a kebab-case id as the first argument',
|
||||
);
|
||||
}
|
||||
const id = validateMountId(args[0], 'mount id');
|
||||
let path: string | undefined;
|
||||
let engine: 'postgres' | 'pglite' | undefined;
|
||||
let database_url: string | undefined;
|
||||
let database_path: string | undefined;
|
||||
let alias: string | undefined;
|
||||
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
const next = (flag: string): string => {
|
||||
const v = args[++i];
|
||||
if (!v) throw new GBrainError(`Missing value for ${flag}`, '', `Pass a value: ${flag} <value>`);
|
||||
return v;
|
||||
};
|
||||
if (a === '--path') path = next('--path');
|
||||
else if (a === '--engine') {
|
||||
const v = next('--engine');
|
||||
if (v !== 'postgres' && v !== 'pglite') {
|
||||
throw new GBrainError(`Invalid engine: "${v}"`, 'Must be "postgres" or "pglite"', 'Pass --engine pglite or --engine postgres');
|
||||
}
|
||||
engine = v;
|
||||
}
|
||||
else if (a === '--db-url' || a === '--database-url') database_url = next(a);
|
||||
else if (a === '--db-path' || a === '--database-path') database_path = next(a);
|
||||
else if (a === '--alias') alias = validateMountId(next('--alias'), '--alias value');
|
||||
else throw new GBrainError(`Unknown flag: ${a}`, '', 'See `gbrain mounts add --help`');
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new GBrainError('Missing --path', 'Every mount needs a local clone path (for skills + handlers)', 'Add --path /absolute/path/to/mount');
|
||||
}
|
||||
|
||||
// Engine inference: if user supplied db-url → postgres, if db-path → pglite.
|
||||
if (!engine) {
|
||||
if (database_url) engine = 'postgres';
|
||||
else if (database_path) engine = 'pglite';
|
||||
else {
|
||||
throw new GBrainError(
|
||||
'Missing --engine',
|
||||
'Could not infer engine from flags',
|
||||
'Pass --engine pglite --db-path <path> OR --engine postgres --db-url <url>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (engine === 'postgres' && !database_url) {
|
||||
throw new GBrainError('postgres mount requires --db-url', '', 'Pass --db-url postgresql://...');
|
||||
}
|
||||
if (engine === 'pglite' && !database_path && !database_url) {
|
||||
throw new GBrainError('pglite mount requires --db-path', '', 'Pass --db-path /path/to/mount/.pglite');
|
||||
}
|
||||
|
||||
return { id, path: resolve(path), engine, database_url, database_path, alias };
|
||||
}
|
||||
|
||||
// ── Subcommand: add ─────────────────────────────────────────────────────
|
||||
|
||||
async function runAdd(args: string[]): Promise<void> {
|
||||
const parsed = parseAddArgs(args);
|
||||
|
||||
// Mount path must exist on disk — otherwise skill/handler loading will
|
||||
// fail later with a less-actionable error.
|
||||
if (!existsSync(parsed.path)) {
|
||||
throw new GBrainError(
|
||||
`Mount path does not exist: ${parsed.path}`,
|
||||
'The local clone directory must exist before registering a mount',
|
||||
`Clone the repo first (git clone <repo> ${parsed.path}) then re-run`,
|
||||
);
|
||||
}
|
||||
|
||||
const file = readMountsFile();
|
||||
|
||||
// Duplicate id check.
|
||||
if (file.mounts.some(m => m.id === parsed.id)) {
|
||||
throw new GBrainError(
|
||||
`Mount id already exists: "${parsed.id}"`,
|
||||
`Use 'gbrain mounts list' to see registered mounts`,
|
||||
`Remove the existing mount first: gbrain mounts remove ${parsed.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate path check (load-bearing — skills/handlers/attestation/git
|
||||
// sync all key off path, so two mounts at the same path silently collide).
|
||||
const existingAtPath = file.mounts.find(m => resolve(m.path) === parsed.path);
|
||||
if (existingAtPath) {
|
||||
throw new DuplicateMountPathError(parsed.path, existingAtPath.id, parsed.id);
|
||||
}
|
||||
|
||||
// Soft warning: same database_url/database_path under different id. A
|
||||
// team can legitimately mount the same remote brain under two aliases,
|
||||
// so this is NOT a hard block (Codex finding #9 correction).
|
||||
const urlDupe = file.mounts.find(m =>
|
||||
(parsed.database_url && m.database_url === parsed.database_url) ||
|
||||
(parsed.database_path && m.database_path === parsed.database_path),
|
||||
);
|
||||
if (urlDupe) {
|
||||
process.stderr.write(
|
||||
`WARN: mount "${parsed.id}" shares database with "${urlDupe.id}". ` +
|
||||
`This is usually a mistake but is allowed for intentional aliasing.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const entry: MountEntry = {
|
||||
id: parsed.id,
|
||||
alias: parsed.alias,
|
||||
path: parsed.path,
|
||||
engine: parsed.engine,
|
||||
database_url: parsed.database_url,
|
||||
database_path: parsed.database_path,
|
||||
enabled: true,
|
||||
};
|
||||
file.mounts.push(entry);
|
||||
writeMountsFile(file);
|
||||
|
||||
process.stdout.write(
|
||||
`Mount "${parsed.id}" added → ${parsed.path}\n` +
|
||||
` engine: ${parsed.engine}\n` +
|
||||
` ${parsed.database_url ? `db_url: ${redactUrl(parsed.database_url)}` : `db_path: ${parsed.database_path}`}\n`,
|
||||
);
|
||||
|
||||
// Publish aggregated resolver + manifest to ~/.gbrain/mounts-cache/. This
|
||||
// is the runtime ownership seam — host agents read the aggregated file
|
||||
// instead of the checked-in skills/RESOLVER.md. When the current process
|
||||
// isn't inside a gbrain repo, skip (a later mounts invocation from a
|
||||
// repo-rooted cwd will publish the cache).
|
||||
refreshMountsCache();
|
||||
}
|
||||
|
||||
// ── Subcommand: list ────────────────────────────────────────────────────
|
||||
|
||||
function runList(args: string[]): void {
|
||||
const jsonMode = args.includes('--json');
|
||||
const file = readMountsFile();
|
||||
|
||||
if (jsonMode) {
|
||||
// Redact raw db_url in json output (mounts.json is per-user 0600, but
|
||||
// stdout can be piped into logs). database_path is fine (it's a local
|
||||
// path, not a secret).
|
||||
const redacted = file.mounts.map(m => ({
|
||||
...m,
|
||||
database_url: m.database_url ? redactUrl(m.database_url) : undefined,
|
||||
}));
|
||||
process.stdout.write(JSON.stringify({ version: file.version, mounts: redacted }, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.mounts.length === 0) {
|
||||
process.stdout.write(
|
||||
'No mounts registered.\n\n' +
|
||||
`Add a mount with:\n` +
|
||||
` gbrain mounts add <id> --path <path> --engine pglite --db-path <path>\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`MOUNTS (${file.mounts.length})\n`);
|
||||
process.stdout.write('─'.repeat(60) + '\n');
|
||||
for (const m of file.mounts) {
|
||||
const status = m.enabled === false ? '(disabled)' : '';
|
||||
process.stdout.write(` ${m.id.padEnd(20)} ${m.engine.padEnd(10)} ${status}\n`);
|
||||
process.stdout.write(` path: ${m.path}\n`);
|
||||
if (m.database_url) {
|
||||
process.stdout.write(` db_url: ${redactUrl(m.database_url)}\n`);
|
||||
} else if (m.database_path) {
|
||||
process.stdout.write(` db_path: ${m.database_path}\n`);
|
||||
}
|
||||
if (m.alias) process.stdout.write(` alias: ${m.alias}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subcommand: remove ──────────────────────────────────────────────────
|
||||
|
||||
function runRemove(args: string[]): void {
|
||||
if (args.length === 0) {
|
||||
throw new GBrainError(
|
||||
'Missing mount id',
|
||||
'gbrain mounts remove <id>',
|
||||
`Run 'gbrain mounts list' to see registered mounts`,
|
||||
);
|
||||
}
|
||||
const id = args[0];
|
||||
if (id === HOST_BRAIN_ID) {
|
||||
throw new GBrainError(
|
||||
`Cannot remove host brain`,
|
||||
`"host" is not a mount — it is the default brain from ~/.gbrain/config.json`,
|
||||
`Use 'gbrain init' to reconfigure the host brain`,
|
||||
);
|
||||
}
|
||||
|
||||
const file = readMountsFile();
|
||||
const before = file.mounts.length;
|
||||
file.mounts = file.mounts.filter(m => m.id !== id);
|
||||
if (file.mounts.length === before) {
|
||||
throw new GBrainError(
|
||||
`Mount "${id}" not found`,
|
||||
`No mount with id "${id}" is registered`,
|
||||
`Run 'gbrain mounts list' to see registered mounts`,
|
||||
);
|
||||
}
|
||||
|
||||
writeMountsFile(file);
|
||||
process.stdout.write(`Mount "${id}" removed from mounts.json\n`);
|
||||
|
||||
// If removing the last mount, clear the cache entirely; otherwise
|
||||
// rewrite with the remaining mounts so the aggregated resolver doesn't
|
||||
// reference stale entries.
|
||||
if (file.mounts.length === 0) {
|
||||
try { clearMountsCache(); } catch { /* best effort */ }
|
||||
} else {
|
||||
refreshMountsCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute + publish ~/.gbrain/mounts-cache/{RESOLVER.md,manifest.json}.
|
||||
* Looks for the host skills dir via findRepoRoot(cwd). When not in a gbrain
|
||||
* repo, skips with a stderr note — next mounts invocation from a
|
||||
* repo-rooted cwd will publish. Failures are non-fatal: the mounts.json
|
||||
* write already succeeded; a stale cache is recoverable via `gbrain mounts
|
||||
* list` (PR 1 will add `gbrain mounts sync --cache` for explicit refresh).
|
||||
*/
|
||||
function refreshMountsCache(): void {
|
||||
const repoRoot = findRepoRoot(process.cwd());
|
||||
if (!repoRoot) {
|
||||
process.stderr.write(
|
||||
'NOTE: mounts-cache not refreshed (not inside a gbrain repo). ' +
|
||||
'Run `gbrain mounts add|remove` from within a repo to publish ' +
|
||||
'the aggregated resolver for host agents.\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hostSkillsDir = join(repoRoot, 'skills');
|
||||
if (!existsSync(hostSkillsDir)) {
|
||||
process.stderr.write(
|
||||
`NOTE: mounts-cache not refreshed (${hostSkillsDir} does not exist).\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const file = readMountsFile();
|
||||
const { resolverPath } = writeMountsCache(hostSkillsDir, file.mounts);
|
||||
process.stderr.write(` cache: ${resolverPath}\n`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`WARN: failed to refresh mounts-cache: ${msg}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Strip password from a postgres:// url for safe display. */
|
||||
function redactUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
|
||||
if (u.password) u.password = '***';
|
||||
return u.toString().replace(/^http:\/\//, 'postgres://');
|
||||
} catch {
|
||||
// Opaque URL (e.g. file:// for pglite). Return as-is.
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dispatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function runMounts(args: string[]): Promise<void> {
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const sub = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
switch (sub) {
|
||||
case 'add':
|
||||
await runAdd(rest);
|
||||
return;
|
||||
case 'list':
|
||||
case 'ls':
|
||||
runList(rest);
|
||||
return;
|
||||
case 'remove':
|
||||
case 'rm':
|
||||
runRemove(rest);
|
||||
return;
|
||||
default:
|
||||
throw new GBrainError(
|
||||
`Unknown subcommand: gbrain mounts ${sub}`,
|
||||
`Supported: add, list, remove`,
|
||||
`Run 'gbrain mounts --help' for usage`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
process.stdout.write(`gbrain mounts — manage connected gbrains (PR 0: direct transport only)
|
||||
|
||||
USAGE
|
||||
gbrain mounts add <id> --path <path> --engine pglite|postgres [--db-url|--db-path]
|
||||
gbrain mounts list [--json]
|
||||
gbrain mounts remove <id>
|
||||
|
||||
EXAMPLES
|
||||
# Mount a team-published yc-media gbrain (PGLite)
|
||||
git clone https://github.com/yc-team/yc-media-gbrain ~/gbrains/yc-media
|
||||
gbrain mounts add yc-media --path ~/gbrains/yc-media --engine pglite \\
|
||||
--db-path ~/gbrains/yc-media/.pglite
|
||||
|
||||
# List registered mounts
|
||||
gbrain mounts list
|
||||
|
||||
# Remove a mount
|
||||
gbrain mounts remove yc-media
|
||||
|
||||
NOT YET IMPLEMENTED (coming in PR 1/2)
|
||||
gbrain mounts pin <id> <sha> — freeze a mount at a tested version
|
||||
gbrain mounts sync [--id <id>] — git pull + refresh attestation
|
||||
gbrain mounts enable|disable <id> — toggle without removing
|
||||
gbrain mounts add --mcp-url <url> — HTTP MCP transport + OAuth
|
||||
`);
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __testing = {
|
||||
parseAddArgs,
|
||||
redactUrl,
|
||||
readMountsFile,
|
||||
writeMountsFile,
|
||||
getMountsPath,
|
||||
};
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 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 { 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 type { BrainEngine } from '../core/engine.ts';
|
||||
import { operations, OperationError } from '../core/operations.ts';
|
||||
import type { OperationContext, AuthInfo } from '../core/operations.ts';
|
||||
import { GBrainOAuthProvider } from '../core/oauth-provider.ts';
|
||||
import { loadConfig } from '../core/config.ts';
|
||||
import { VERSION } from '../version.ts';
|
||||
import * as db from '../core/db.ts';
|
||||
|
||||
interface ServeHttpOptions {
|
||||
port: number;
|
||||
tokenTtl: number;
|
||||
enableDcr: 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;
|
||||
}
|
||||
|
||||
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
|
||||
const { port, tokenTtl, enableDcr, publicUrl } = options;
|
||||
const config = loadConfig() || { engine: 'pglite' as const };
|
||||
|
||||
// Get raw SQL connection for OAuth provider
|
||||
const sql = db.getConnection();
|
||||
|
||||
// Initialize OAuth provider
|
||||
const oauthProvider = new GBrainOAuthProvider({
|
||||
sql: sql as any,
|
||||
tokenTtl,
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Generate bootstrap token for admin dashboard
|
||||
const bootstrapToken = randomBytes(32).toString('hex');
|
||||
const bootstrapHash = createHash('sha256').update(bootstrapToken).digest('hex');
|
||||
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();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie parsing — required for /admin auth (express 5 has no built-in)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use(cookieParser());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CORS
|
||||
// ---------------------------------------------------------------------------
|
||||
app.use('/mcp', cors());
|
||||
app.use('/token', cors());
|
||||
app.use('/authorize', cors());
|
||||
app.use('/register', cors());
|
||||
app.use('/revoke', cors());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.' },
|
||||
});
|
||||
|
||||
app.post('/token', ccRateLimiter, express.urlencoded({ extended: false }), async (req, res, next) => {
|
||||
if (req.body?.grant_type !== 'client_credentials') {
|
||||
return next(); // Fall through to SDK's token handler
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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}`);
|
||||
|
||||
const authRouterOptions: any = {
|
||||
provider: oauthProvider,
|
||||
issuerUrl,
|
||||
scopesSupported: ['read', 'write', 'admin'],
|
||||
resourceName: 'GBrain MCP Server',
|
||||
};
|
||||
|
||||
// Disable DCR by removing registerClient from the clients store
|
||||
if (!enableDcr) {
|
||||
// Override the provider's clientsStore to remove registerClient
|
||||
const originalStore = oauthProvider.clientsStore;
|
||||
(oauthProvider as any)._clientsStore = {
|
||||
getClient: originalStore.getClient.bind(originalStore),
|
||||
// No registerClient = DCR disabled
|
||||
};
|
||||
}
|
||||
|
||||
app.use(mcpAuthRouter(authRouterOptions));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/health', async (_req, res) => {
|
||||
try {
|
||||
const stats = await engine.getStats();
|
||||
res.json({ status: 'ok', version: VERSION, engine: config.engine, ...stats });
|
||||
} catch {
|
||||
res.status(503).json({ error: 'service_unavailable', error_description: 'Database connection failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin authentication (cookie-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/admin/login', express.json(), (req, res) => {
|
||||
const token = req.body?.token;
|
||||
if (!token) {
|
||||
res.status(400).json({ error: 'Token required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
if (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, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
path: '/admin',
|
||||
});
|
||||
res.json({ status: 'authenticated' });
|
||||
});
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/admin/api/agents', requireAdmin, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const agents = await sql`
|
||||
SELECT client_id, client_name, grant_types, scope, created_at
|
||||
FROM oauth_clients ORDER BY created_at DESC
|
||||
`;
|
||||
res.json(agents);
|
||||
} catch (e) {
|
||||
res.status(503).json({ error: 'service_unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
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'`;
|
||||
res.json({
|
||||
connected_agents: (clients as any).count,
|
||||
active_tokens: (tokens 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' });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
let query = `SELECT * FROM mcp_request_log WHERE 1=1`;
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (agent && agent !== 'all') { query += ` AND token_name = $${paramIdx++}`; params.push(agent); }
|
||||
if (operation && operation !== 'all') { query += ` AND operation = $${paramIdx++}`; params.push(operation); }
|
||||
if (status && status !== 'all') { query += ` AND status = $${paramIdx++}`; params.push(status); }
|
||||
|
||||
query += ` ORDER BY created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
// Use raw query for dynamic filtering
|
||||
const rows = await sql`SELECT * FROM mcp_request_log ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}`;
|
||||
const [countResult] = await sql`SELECT count(*)::int as total FROM mcp_request_log`;
|
||||
res.json({ rows, total: (countResult as any).total, page, pages: Math.ceil((countResult as any).total / limit) });
|
||||
} catch {
|
||||
res.status(503).json({ error: 'service_unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// Register client from admin dashboard
|
||||
app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, scopes } = req.body;
|
||||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||||
const result = await oauthProvider.registerClientManual(
|
||||
name, ['client_credentials'], scopes || 'read', [],
|
||||
);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : 'Registration 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
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serve from admin/dist if it exists (development), otherwise embedded assets
|
||||
const path = await import('path');
|
||||
const fs = await import('fs');
|
||||
const adminDistPath = path.join(process.cwd(), 'admin', 'dist');
|
||||
if (fs.existsSync(adminDistPath)) {
|
||||
app.use('/admin', express.static(adminDistPath));
|
||||
// SPA fallback: serve index.html for all unmatched /admin/* routes
|
||||
app.get('/admin/*', (req: Request, res: Response, next: NextFunction) => {
|
||||
// Skip API and events routes
|
||||
if (req.path.startsWith('/admin/api/') || req.path === '/admin/events' || req.path === '/admin/login') {
|
||||
return next();
|
||||
}
|
||||
res.sendFile(path.join(adminDistPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP tool calls (bearer auth + scope enforcement)
|
||||
// ---------------------------------------------------------------------------
|
||||
const mcpOperations = operations.filter(op => !op.localOnly);
|
||||
|
||||
app.post('/mcp', requireBearerAuth({ verifier: oauthProvider }), async (req: Request, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
const authInfo = (req as any).auth as AuthInfo;
|
||||
|
||||
// Create a fresh MCP server per request (stateless)
|
||||
const server = new Server(
|
||||
{ name: 'gbrain', version: VERSION },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
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, {
|
||||
type: v.type,
|
||||
description: v.description,
|
||||
...(v.enum ? { enum: v.enum } : {}),
|
||||
...(v.default !== undefined ? { default: v.default } : {}),
|
||||
}]),
|
||||
),
|
||||
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) {
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown_operation', message: `Unknown: ${name}` }) }] };
|
||||
}
|
||||
|
||||
// Scope enforcement
|
||||
const requiredScope = op.scope || 'read';
|
||||
if (!authInfo.scopes.includes(requiredScope)) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
error: 'insufficient_scope',
|
||||
message: `Operation ${name} requires '${requiredScope}' scope`,
|
||||
your_scopes: authInfo.scopes,
|
||||
}),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const ctx: OperationContext = {
|
||||
engine,
|
||||
config,
|
||||
logger: {
|
||||
info: (msg: string) => console.error(`[INFO] ${msg}`),
|
||||
warn: (msg: string) => console.error(`[WARN] ${msg}`),
|
||||
error: (msg: string) => console.error(`[ERROR] ${msg}`),
|
||||
},
|
||||
dryRun: !!(params?.dry_run),
|
||||
auth: authInfo,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await op.handler(ctx, (params || {}) as Record<string, unknown>);
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
// Log request + broadcast to SSE
|
||||
try {
|
||||
await sql`INSERT INTO mcp_request_log (token_name, operation, latency_ms, status)
|
||||
VALUES (${authInfo.clientId}, ${name}, ${latency}, ${'success'})`;
|
||||
} catch { /* best effort */ }
|
||||
|
||||
broadcastEvent({
|
||||
agent: authInfo.clientId,
|
||||
operation: name,
|
||||
scopes: authInfo.scopes.join(','),
|
||||
latency_ms: latency,
|
||||
status: 'success',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
||||
} catch (e) {
|
||||
const latency = Date.now() - startTime;
|
||||
const error = e instanceof OperationError ? e.toJSON() : { error: 'internal_error', message: e instanceof Error ? e.message : 'Unknown error' };
|
||||
|
||||
try {
|
||||
await sql`INSERT INTO mcp_request_log (token_name, operation, latency_ms, status)
|
||||
VALUES (${authInfo.clientId}, ${name}, ${latency}, ${'error'})`;
|
||||
} catch { /* best effort */ }
|
||||
|
||||
broadcastEvent({
|
||||
agent: authInfo.clientId,
|
||||
operation: name,
|
||||
latency_ms: latency,
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify(error) }], isError: true };
|
||||
}
|
||||
});
|
||||
|
||||
// Use StreamableHTTPServerTransport for stateless request handling
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined as any });
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start server
|
||||
// ---------------------------------------------------------------------------
|
||||
const clientCount = await sql`SELECT count(*)::int as count FROM oauth_clients`;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.error(`
|
||||
╔══════════════════════════════════════════════════════╗
|
||||
║ GBrain MCP Server v${VERSION.padEnd(37)}║
|
||||
╠══════════════════════════════════════════════════════╣
|
||||
║ Port: ${String(port).padEnd(40)}║
|
||||
║ Engine: ${(config.engine || 'pglite').padEnd(40)}║
|
||||
║ Issuer: ${issuerUrl.origin.padEnd(40)}║
|
||||
║ Clients: ${String((clientCount[0] as any).count).padEnd(40)}║
|
||||
║ DCR: ${(enableDcr ? 'enabled' : 'disabled').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))}║
|
||||
╠══════════════════════════════════════════════════════╣
|
||||
║ Admin Token (paste into /admin login): ║
|
||||
║ ${bootstrapToken.substring(0, 50)} ║
|
||||
║ ${bootstrapToken.substring(50).padEnd(50)} ║
|
||||
╚══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
}
|
||||
+21
-9
@@ -1,17 +1,29 @@
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { startMcpServer } from '../mcp/server.ts';
|
||||
import { startHttpTransport } from '../mcp/http-transport.ts';
|
||||
|
||||
export async function runServe(engine: BrainEngine, args: string[] = []) {
|
||||
const useHttp = args.includes('--http');
|
||||
const portIdx = args.indexOf('--port');
|
||||
const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) || 8787 : 8787;
|
||||
// v0.26+: --http dispatches to the full OAuth 2.1 server (serve-http.ts)
|
||||
// with admin dashboard, scope enforcement, SSE feed, and the requireBearerAuth
|
||||
// middleware. Master's simpler startHttpTransport from v0.22.7 is superseded
|
||||
// — the OAuth provider in serve-http.ts handles bearer auth via
|
||||
// verifyAccessToken with legacy access_tokens fallback (so v0.22.7 callers
|
||||
// that used `gbrain auth create` keep working unchanged).
|
||||
const isHttp = args.includes('--http');
|
||||
|
||||
if (useHttp) {
|
||||
console.error(`Starting GBrain MCP server (HTTP on port ${port})...`);
|
||||
await startHttpTransport({ port, engine });
|
||||
// Keep alive
|
||||
await new Promise(() => {});
|
||||
if (isHttp) {
|
||||
const portIdx = args.indexOf('--port');
|
||||
const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) || 3131 : 3131;
|
||||
|
||||
const ttlIdx = args.indexOf('--token-ttl');
|
||||
const tokenTtl = ttlIdx >= 0 ? parseInt(args[ttlIdx + 1]) || 3600 : 3600;
|
||||
|
||||
const enableDcr = args.includes('--enable-dcr');
|
||||
|
||||
const publicUrlIdx = args.indexOf('--public-url');
|
||||
const publicUrl = publicUrlIdx >= 0 ? args[publicUrlIdx + 1] : undefined;
|
||||
|
||||
const { runServeHttp } = await import('./serve-http.ts');
|
||||
await runServeHttp(engine, { port, tokenTtl, enableDcr, publicUrl });
|
||||
} else {
|
||||
console.error('Starting GBrain MCP server (stdio)...');
|
||||
await startMcpServer(engine);
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* BrainRegistry — connected gbrains (v0.19, PR 0).
|
||||
*
|
||||
* A registry of BrainEngine handles keyed by brainId. Supports:
|
||||
* - 'host': the brain defined by ~/.gbrain/config.json (single-brain default).
|
||||
* - <mount-id>: brains declared in ~/.gbrain/mounts.json.
|
||||
*
|
||||
* This is the dispatch-time lookup that makes `ctx.brainId` → `ctx.engine`
|
||||
* resolution routable per operation. Only direct-transport mounts are
|
||||
* supported in PR 0. HTTP MCP transport (team-published brains with OAuth)
|
||||
* lands in PR 2.
|
||||
*
|
||||
* Design notes:
|
||||
* - Engines are lazily created on first `getBrain(id)` and cached.
|
||||
* - `disconnectAll()` is idempotent and safe to call during shutdown.
|
||||
* - NO AsyncLocalStorage. Brain routing is explicit via OperationContext.
|
||||
* - mounts.json is validated strictly on load. Malformed entries throw with
|
||||
* actionable messages so partial-state silent failures never happen.
|
||||
* - `DuplicateMountPathError` blocks two mounts pointing at the same local
|
||||
* path (load-bearing identity: skills/handlers/git-sync/attestation all
|
||||
* key off path). Same db_url is not blocked because a team can
|
||||
* legitimately mount the same remote brain under two local clones.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import type { EngineConfig } from './types.ts';
|
||||
import { GBrainError } from './types.ts';
|
||||
import { loadConfig, type GBrainConfig } from './config.ts';
|
||||
|
||||
/** Host brain id. Reserved — users cannot create a mount with this id. */
|
||||
export const HOST_BRAIN_ID = 'host';
|
||||
|
||||
/** Brain id regex. Alphanumeric + dashes, 1-32 chars. No edge dashes. */
|
||||
const BRAIN_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
||||
|
||||
/** Path to mounts.json. Lazy to avoid homedir() at module scope. */
|
||||
function getMountsPath(): string {
|
||||
return join(homedir(), '.gbrain', 'mounts.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* A single entry in ~/.gbrain/mounts.json.
|
||||
*
|
||||
* PR 0: only direct-transport mounts are supported. PR 2 will add
|
||||
* `transport: "mcp"` with `mcp_url` + OAuth credential references.
|
||||
*/
|
||||
export interface MountEntry {
|
||||
/** Unique mount id. Becomes the namespace in `yc-media::skill` form. */
|
||||
id: string;
|
||||
/** Optional shorthand for CLI display. Must pass BRAIN_ID_RE if present. */
|
||||
alias?: string;
|
||||
/** Absolute local path to the mount's git clone (for skills + handlers). */
|
||||
path: string;
|
||||
/** Engine kind. Required for direct transport. */
|
||||
engine: 'postgres' | 'pglite';
|
||||
/** Postgres connection URL (if engine=postgres). */
|
||||
database_url?: string;
|
||||
/** PGLite data-directory path (if engine=pglite). */
|
||||
database_path?: string;
|
||||
/** Default true. Disabled mounts are not loaded. */
|
||||
enabled?: boolean;
|
||||
/** Managed by `gbrain mounts sync` (PR 1). */
|
||||
expected_sha?: string;
|
||||
/** Managed by `gbrain mounts sync` (PR 1). */
|
||||
last_synced_at?: string;
|
||||
}
|
||||
|
||||
/** Top-level shape of ~/.gbrain/mounts.json. */
|
||||
export interface MountsFile {
|
||||
version: 1;
|
||||
mounts: MountEntry[];
|
||||
}
|
||||
|
||||
/** Handle returned by the registry for a given brain id. */
|
||||
export interface BrainHandle {
|
||||
/** 'host' for the default brain, else the mount id. */
|
||||
id: string;
|
||||
/** Connected BrainEngine. Only valid for the lifetime of this registry. */
|
||||
engine: BrainEngine;
|
||||
/** GBrainConfig used to create the engine. */
|
||||
config: GBrainConfig;
|
||||
/** Absolute local path to the mount's clone. `null` for the host brain. */
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
/** Error thrown when two mounts resolve to the same local path. */
|
||||
export class DuplicateMountPathError extends GBrainError {
|
||||
constructor(path: string, existingId: string, attemptedId: string) {
|
||||
super(
|
||||
`Duplicate mount path: "${path}"`,
|
||||
`Mount "${existingId}" already uses this path. Cannot register "${attemptedId}" at the same location.`,
|
||||
'Use a different local clone path, or remove the existing mount first: ' +
|
||||
`gbrain mounts remove ${existingId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Error thrown when a caller requests an unknown or disabled brain id. */
|
||||
export class UnknownBrainError extends GBrainError {
|
||||
constructor(id: string, available: string[]) {
|
||||
const list = available.length > 0 ? available.join(', ') : '(none registered)';
|
||||
super(
|
||||
`Unknown brain: "${id}"`,
|
||||
`No enabled mount with id "${id}" found. Available brain ids: ${list}`,
|
||||
`Run 'gbrain mounts list' to see registered mounts. Add a new mount with 'gbrain mounts add ${id} --path <path> --db-url <url>'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a mount id (and optionally the alias). Throws with actionable msg. */
|
||||
export function validateMountId(id: unknown, fieldLabel = 'mount id'): string {
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
throw new GBrainError(
|
||||
`Invalid ${fieldLabel}`,
|
||||
`${fieldLabel} must be a non-empty string`,
|
||||
'Use a kebab-case id like "yc-media" or "garrys-list"',
|
||||
);
|
||||
}
|
||||
if (id === HOST_BRAIN_ID) {
|
||||
throw new GBrainError(
|
||||
`Reserved ${fieldLabel}: "${HOST_BRAIN_ID}"`,
|
||||
`"${HOST_BRAIN_ID}" is the host brain id and cannot be used for a mount`,
|
||||
'Choose a different id',
|
||||
);
|
||||
}
|
||||
if (!BRAIN_ID_RE.test(id)) {
|
||||
throw new GBrainError(
|
||||
`Invalid ${fieldLabel}: "${id}"`,
|
||||
`${fieldLabel} must match [a-z0-9-]{1,32}, start+end alphanumeric, interior dashes allowed`,
|
||||
'Use a kebab-case id like "yc-media"',
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate mounts.json. Returns an empty list if the file is absent.
|
||||
* Throws a structured error on any malformed entry (never a silent skip).
|
||||
*/
|
||||
export function loadMounts(mountsPath: string = getMountsPath()): MountEntry[] {
|
||||
if (!existsSync(mountsPath)) return [];
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(mountsPath, 'utf-8');
|
||||
} catch (e) {
|
||||
throw new GBrainError(
|
||||
`Cannot read ${mountsPath}`,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
`Check file permissions (expected 0600) and re-run`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new GBrainError(
|
||||
`Malformed mounts.json`,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
`Fix the JSON syntax at ${mountsPath} or remove it and re-add mounts via 'gbrain mounts add'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new GBrainError(
|
||||
`mounts.json must be a JSON object`,
|
||||
`Got: ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
|
||||
`Expected { version: 1, mounts: [...] }`,
|
||||
);
|
||||
}
|
||||
|
||||
const file = parsed as Partial<MountsFile>;
|
||||
if (file.version !== 1) {
|
||||
throw new GBrainError(
|
||||
`Unsupported mounts.json version: ${file.version}`,
|
||||
`This gbrain binary supports version 1`,
|
||||
`Upgrade gbrain or regenerate mounts.json`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(file.mounts)) {
|
||||
throw new GBrainError(
|
||||
`mounts.json: "mounts" must be an array`,
|
||||
`Got: ${typeof file.mounts}`,
|
||||
`Expected { version: 1, mounts: [...] }`,
|
||||
);
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenPaths = new Map<string, string>(); // resolved path → id
|
||||
const out: MountEntry[] = [];
|
||||
|
||||
for (let i = 0; i < file.mounts.length; i++) {
|
||||
const entry = file.mounts[i] as Partial<MountEntry> | undefined;
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new GBrainError(
|
||||
`mounts.json: entry ${i} must be an object`,
|
||||
`Got: ${typeof entry}`,
|
||||
`Each entry shape: { id, path, engine, db_url|database_path, enabled? }`,
|
||||
);
|
||||
}
|
||||
const id = validateMountId(entry.id, `mounts[${i}].id`);
|
||||
if (seenIds.has(id)) {
|
||||
throw new GBrainError(
|
||||
`mounts.json: duplicate id "${id}"`,
|
||||
`Two mounts share the id "${id}" (only one entry permitted per id)`,
|
||||
`Remove one of the entries or rename it`,
|
||||
);
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
if (typeof entry.path !== 'string' || entry.path.length === 0) {
|
||||
throw new GBrainError(
|
||||
`mounts[${i}] "${id}": path is required`,
|
||||
`path must be a non-empty absolute filesystem path`,
|
||||
`Add "path": "/absolute/path/to/${id}" to this mount entry`,
|
||||
);
|
||||
}
|
||||
const resolvedPath = resolve(entry.path);
|
||||
const existingAtPath = seenPaths.get(resolvedPath);
|
||||
if (existingAtPath) {
|
||||
throw new DuplicateMountPathError(resolvedPath, existingAtPath, id);
|
||||
}
|
||||
seenPaths.set(resolvedPath, id);
|
||||
|
||||
if (entry.engine !== 'postgres' && entry.engine !== 'pglite') {
|
||||
throw new GBrainError(
|
||||
`mounts[${i}] "${id}": engine must be "postgres" or "pglite"`,
|
||||
`Got: ${JSON.stringify(entry.engine)}`,
|
||||
`Set "engine": "pglite" for a local embedded DB or "postgres" for Supabase/self-hosted`,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.engine === 'postgres' && !entry.database_url) {
|
||||
throw new GBrainError(
|
||||
`mounts[${i}] "${id}": postgres mount requires database_url`,
|
||||
`database_url is missing`,
|
||||
`Add "database_url": "postgresql://..." or use engine: "pglite"`,
|
||||
);
|
||||
}
|
||||
if (entry.engine === 'pglite' && !entry.database_path && !entry.database_url) {
|
||||
throw new GBrainError(
|
||||
`mounts[${i}] "${id}": pglite mount requires database_path (or database_url)`,
|
||||
`Both database_path and database_url are missing`,
|
||||
`Add "database_path": "/path/to/${id}/.pglite"`,
|
||||
);
|
||||
}
|
||||
if (entry.alias !== undefined) {
|
||||
validateMountId(entry.alias, `mounts[${i}].alias`);
|
||||
}
|
||||
|
||||
out.push({
|
||||
id,
|
||||
alias: entry.alias,
|
||||
path: resolvedPath,
|
||||
engine: entry.engine,
|
||||
database_url: entry.database_url,
|
||||
database_path: entry.database_path,
|
||||
enabled: entry.enabled ?? true,
|
||||
expected_sha: entry.expected_sha,
|
||||
last_synced_at: entry.last_synced_at,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a MountEntry to an EngineConfig suitable for createEngine. */
|
||||
function mountToEngineConfig(mount: MountEntry): EngineConfig {
|
||||
return {
|
||||
engine: mount.engine,
|
||||
database_url: mount.database_url,
|
||||
database_path: mount.database_path,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a MountEntry to a GBrainConfig (for OperationContext). */
|
||||
function mountToGBrainConfig(mount: MountEntry): GBrainConfig {
|
||||
return {
|
||||
engine: mount.engine,
|
||||
database_url: mount.database_url,
|
||||
database_path: mount.database_path,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed registry of BrainHandles. Lazy-initialized; engines are constructed
|
||||
* on first `getBrain(id)` call and cached.
|
||||
*/
|
||||
export class BrainRegistry {
|
||||
private readonly mounts: Map<string, MountEntry>;
|
||||
private readonly handles = new Map<string, BrainHandle>();
|
||||
private readonly pending = new Map<string, Promise<BrainHandle>>();
|
||||
private hostHandle: BrainHandle | null = null;
|
||||
private pendingHost: Promise<BrainHandle> | null = null;
|
||||
|
||||
constructor(mounts: MountEntry[]) {
|
||||
this.mounts = new Map();
|
||||
for (const m of mounts) {
|
||||
if (m.enabled !== false) this.mounts.set(m.id, m);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a brain id to a connected handle. Returns the host brain for
|
||||
* `id === 'host'` (or undefined). Throws UnknownBrainError for unknown ids.
|
||||
*/
|
||||
async getBrain(id: string | undefined | null): Promise<BrainHandle> {
|
||||
const resolved = id && id.length > 0 ? id : HOST_BRAIN_ID;
|
||||
if (resolved === HOST_BRAIN_ID) return this.getDefaultBrain();
|
||||
|
||||
const cached = this.handles.get(resolved);
|
||||
if (cached) return cached;
|
||||
|
||||
// Dedup concurrent init: if two callers race on the same id, only one
|
||||
// createEngine() fires.
|
||||
const inflight = this.pending.get(resolved);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const mount = this.mounts.get(resolved);
|
||||
if (!mount) throw new UnknownBrainError(resolved, this.listBrainIds());
|
||||
|
||||
const promise = this.initMountBrain(mount);
|
||||
this.pending.set(resolved, promise);
|
||||
try {
|
||||
const handle = await promise;
|
||||
this.handles.set(resolved, handle);
|
||||
return handle;
|
||||
} finally {
|
||||
this.pending.delete(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the host brain handle (from ~/.gbrain/config.json). Lazy-
|
||||
* initialized so callers that only touch mounts don't require host config.
|
||||
*/
|
||||
async getDefaultBrain(): Promise<BrainHandle> {
|
||||
if (this.hostHandle) return this.hostHandle;
|
||||
if (this.pendingHost) return this.pendingHost;
|
||||
|
||||
this.pendingHost = this.initHostBrain();
|
||||
try {
|
||||
this.hostHandle = await this.pendingHost;
|
||||
return this.hostHandle;
|
||||
} finally {
|
||||
this.pendingHost = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return every known brain id (host + enabled mounts). */
|
||||
listBrainIds(): string[] {
|
||||
return [HOST_BRAIN_ID, ...Array.from(this.mounts.keys()).sort()];
|
||||
}
|
||||
|
||||
/** Return the underlying mount entries (host excluded). */
|
||||
listMounts(): MountEntry[] {
|
||||
return Array.from(this.mounts.values());
|
||||
}
|
||||
|
||||
/** Disconnect every initialized engine. Safe to call repeatedly. */
|
||||
async disconnectAll(): Promise<void> {
|
||||
const handles = [this.hostHandle, ...Array.from(this.handles.values())].filter(
|
||||
(h): h is BrainHandle => h != null,
|
||||
);
|
||||
this.hostHandle = null;
|
||||
this.handles.clear();
|
||||
await Promise.allSettled(handles.map(h => h.engine.disconnect()));
|
||||
}
|
||||
|
||||
private async initHostBrain(): Promise<BrainHandle> {
|
||||
const config = loadConfig();
|
||||
if (!config) {
|
||||
throw new GBrainError(
|
||||
'No host brain configured',
|
||||
'~/.gbrain/config.json is missing and GBRAIN_DATABASE_URL is unset',
|
||||
"Run 'gbrain init' to configure the host brain",
|
||||
);
|
||||
}
|
||||
const { createEngine } = await import('./engine-factory.ts');
|
||||
const engineConfig: EngineConfig = {
|
||||
engine: config.engine,
|
||||
database_url: config.database_url,
|
||||
database_path: config.database_path,
|
||||
};
|
||||
const engine = await createEngine(engineConfig);
|
||||
await engine.connect(engineConfig);
|
||||
return { id: HOST_BRAIN_ID, engine, config, path: null };
|
||||
}
|
||||
|
||||
private async initMountBrain(mount: MountEntry): Promise<BrainHandle> {
|
||||
const { createEngine } = await import('./engine-factory.ts');
|
||||
const engineConfig = mountToEngineConfig(mount);
|
||||
const engine = await createEngine(engineConfig);
|
||||
// Mounts MUST use per-instance connection pools, never the module
|
||||
// singleton in db.ts. Passing poolSize forces postgres-engine onto the
|
||||
// instance path (postgres-engine.ts:33-60). Without this, two mounts
|
||||
// with different Postgres URLs silently share whichever singleton was
|
||||
// connected first (Codex finding #1). PGLite ignores poolSize — it has
|
||||
// no pool. Hard-coded 5: conservative cap for mounts given N brains
|
||||
// can be mounted at once. Override per-mount is PR 1.
|
||||
await engine.connect({ ...engineConfig, poolSize: 5 } as EngineConfig & { poolSize: number });
|
||||
return {
|
||||
id: mount.id,
|
||||
engine,
|
||||
config: mountToGBrainConfig(mount),
|
||||
path: mount.path,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: build a registry from the default mounts.json location. */
|
||||
export function loadRegistry(mountsPath: string = getMountsPath()): BrainRegistry {
|
||||
return new BrainRegistry(loadMounts(mountsPath));
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __testing = {
|
||||
BRAIN_ID_RE,
|
||||
getMountsPath,
|
||||
mountToEngineConfig,
|
||||
mountToGBrainConfig,
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Brain resolution for CLI commands (v0.19.0, PR 0).
|
||||
*
|
||||
* Mirrors the 6-tier resolution pattern of v0.18.0's source-resolver.ts so
|
||||
* agents learn one mental model. `--brain <id>` picks WHICH DATABASE to
|
||||
* target (mounts + host). `--source <id>` (v0.18.0) picks WHICH REPO WITHIN
|
||||
* the selected brain. Orthogonal axes.
|
||||
*
|
||||
* Resolution priority (highest first):
|
||||
* 1. Explicit --brain <id> flag (caller passes this as `explicit`).
|
||||
* 2. GBRAIN_BRAIN_ID env var.
|
||||
* 3. .gbrain-mount dotfile in CWD or any ancestor directory.
|
||||
* 4. Registered mount whose `path` contains CWD (longest-prefix match).
|
||||
* 5. Brain-level default (future: ~/.gbrain/config.json `brains.default`).
|
||||
* 6. Literal 'host' fallback (backward compat for every pre-v0.19 brain).
|
||||
*
|
||||
* Consumed by src/cli.ts, src/mcp/server.ts, and any future command that
|
||||
* needs per-call brain selection. The subagent handler inherits the
|
||||
* parent's brainId instead of re-running this resolver.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, dirname, resolve } from 'path';
|
||||
import { HOST_BRAIN_ID, loadMounts, validateMountId, type MountEntry } from './brain-registry.ts';
|
||||
|
||||
const DOTFILE = '.gbrain-mount';
|
||||
/** Same regex as brain-registry. Kept in sync. */
|
||||
const BRAIN_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
||||
|
||||
/**
|
||||
* Walk up from startDir looking for a .gbrain-mount dotfile. Returns the
|
||||
* first valid id found, or null if none. Guards against filesystem-root
|
||||
* infinite loops and malformed dotfiles (silent skip + continue walking).
|
||||
*/
|
||||
function readDotfileWalk(startDir: string): string | null {
|
||||
let dir = resolve(startDir);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const candidate = join(dir, DOTFILE);
|
||||
if (existsSync(candidate)) {
|
||||
try {
|
||||
const content = readFileSync(candidate, 'utf8').trim().split('\n')[0].trim();
|
||||
if (content === HOST_BRAIN_ID) return content;
|
||||
if (BRAIN_ID_RE.test(content)) return content;
|
||||
} catch {
|
||||
// Unreadable dotfile — skip and keep walking.
|
||||
}
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break; // filesystem root
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Longest-prefix match: find the mount whose `path` contains `cwd`. */
|
||||
function longestPathPrefixMount(mounts: MountEntry[], cwd: string): MountEntry | null {
|
||||
const cwdResolved = resolve(cwd);
|
||||
let best: { mount: MountEntry; pathLen: number } | null = null;
|
||||
for (const m of mounts) {
|
||||
if (m.enabled === false) continue;
|
||||
const p = resolve(m.path);
|
||||
if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
|
||||
if (!best || p.length > best.pathLen) {
|
||||
best = { mount: m, pathLen: p.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best ? best.mount : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the brain id for a CLI command. Never returns null — every call
|
||||
* targets exactly one brain, with 'host' as the guaranteed terminal fallback.
|
||||
*
|
||||
* @param explicit The --brain <id> flag value, if the caller parsed one.
|
||||
* @param cwd Working directory for .gbrain-mount walk. Defaults to process.cwd().
|
||||
* @param mountsLoader Override for testability. Returns the list of enabled
|
||||
* mounts. Defaults to reading ~/.gbrain/mounts.json.
|
||||
* @returns The resolved brain id. Always truthy. Either 'host' or a valid mount id.
|
||||
*
|
||||
* Does NOT validate that the id points at a registered mount — that is
|
||||
* BrainRegistry.getBrain's job. This resolver answers "which id is the
|
||||
* caller asking for?"; the registry answers "does that id exist?".
|
||||
*/
|
||||
export function resolveBrainId(
|
||||
explicit: string | null | undefined,
|
||||
cwd: string = process.cwd(),
|
||||
mountsLoader: () => MountEntry[] = loadMounts,
|
||||
): string {
|
||||
// 1. Explicit flag wins.
|
||||
if (explicit) {
|
||||
if (explicit === HOST_BRAIN_ID) return HOST_BRAIN_ID;
|
||||
validateMountId(explicit, '--brain value');
|
||||
return explicit;
|
||||
}
|
||||
|
||||
// 2. Env var.
|
||||
const env = process.env.GBRAIN_BRAIN_ID;
|
||||
if (env && env.length > 0) {
|
||||
if (env === HOST_BRAIN_ID) return HOST_BRAIN_ID;
|
||||
validateMountId(env, 'GBRAIN_BRAIN_ID');
|
||||
return env;
|
||||
}
|
||||
|
||||
// 3. Dotfile walk-up.
|
||||
const dotfile = readDotfileWalk(cwd);
|
||||
if (dotfile) return dotfile;
|
||||
|
||||
// 4. Registered mount path-prefix.
|
||||
let mounts: MountEntry[] = [];
|
||||
try {
|
||||
mounts = mountsLoader();
|
||||
} catch {
|
||||
// mounts.json corruption shouldn't break brain resolution — fall through
|
||||
// to 'host'. BrainRegistry.getBrain will throw the actionable error if
|
||||
// the caller actually tried to touch a mount.
|
||||
mounts = [];
|
||||
}
|
||||
const matched = longestPathPrefixMount(mounts, cwd);
|
||||
if (matched) return matched.id;
|
||||
|
||||
// 5. Brain-level default — v2. Not wired in PR 0.
|
||||
// 6. Fallback.
|
||||
return HOST_BRAIN_ID;
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __testing = {
|
||||
readDotfileWalk,
|
||||
longestPathPrefixMount,
|
||||
DOTFILE,
|
||||
BRAIN_ID_RE,
|
||||
};
|
||||
@@ -1201,6 +1201,68 @@ export const MIGRATIONS: Migration[] = [
|
||||
},
|
||||
sql: '',
|
||||
},
|
||||
{
|
||||
version: 32,
|
||||
name: 'oauth_infrastructure',
|
||||
// v0.26 OAuth 2.1 tables for `gbrain serve --http`. Supports client credentials,
|
||||
// authorization code + PKCE, and refresh token rotation. Renumbered from v30
|
||||
// → v32 on merge with master's v0.23 (dream_verdicts at v30) + v0.25
|
||||
// (eval_capture_tables at v31). OAuth is independent of those chains so
|
||||
// ordering doesn't matter beyond version ledger correctness. CREATE TABLE
|
||||
// statements are idempotent so brains that previously applied this at v30
|
||||
// see version 32 as new and run IF NOT EXISTS DDL cleanly.
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_secret_hash TEXT,
|
||||
client_name TEXT NOT NULL,
|
||||
redirect_uris TEXT[],
|
||||
grant_types TEXT[] DEFAULT '{"client_credentials"}',
|
||||
scope TEXT,
|
||||
token_endpoint_auth_method TEXT,
|
||||
client_id_issued_at BIGINT,
|
||||
client_secret_expires_at BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
token_type TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
expires_at BIGINT,
|
||||
resource TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_expiry ON oauth_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_client ON oauth_tokens(client_id);
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
|
||||
redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
resource TEXT,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_log_time_agent ON mcp_request_log(created_at, token_name);
|
||||
DO $$
|
||||
DECLARE
|
||||
has_bypass BOOLEAN;
|
||||
BEGIN
|
||||
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
||||
IF has_bypass THEN
|
||||
ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE oauth_codes ENABLE ROW LEVEL SECURITY;
|
||||
ELSE
|
||||
RAISE WARNING 'v32: role % lacks BYPASSRLS — skipping RLS on OAuth tables. Re-run as postgres (or a BYPASSRLS role) to harden.', current_user;
|
||||
END IF;
|
||||
END $$;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export const LATEST_VERSION = MIGRATIONS.length > 0
|
||||
|
||||
@@ -149,13 +149,16 @@ export function makeSubagentHandler(deps: SubagentDeps) {
|
||||
const systemPrompt = data.system ?? DEFAULT_SYSTEM;
|
||||
|
||||
// Build the tool registry bound to THIS job as the owning subagent.
|
||||
// allowed_slug_prefixes (v0.23) flows through buildBrainTools → the
|
||||
// put_page schema description AND the OperationContext, so the model's
|
||||
// tool schema and the server-side check stay in sync.
|
||||
// brain_id (per-call brain override; children inherit parent's unless
|
||||
// they set their own) and allowed_slug_prefixes (v0.23 trusted-workspace
|
||||
// allow-list — flows through buildBrainTools → the put_page schema
|
||||
// description AND the OperationContext, so the model's tool schema and
|
||||
// the server-side check stay in sync).
|
||||
const registry = deps.toolRegistry ?? buildBrainTools({
|
||||
subagentId: ctx.id,
|
||||
engine,
|
||||
config,
|
||||
brainId: data.brain_id,
|
||||
allowedSlugPrefixes: data.allowed_slug_prefixes,
|
||||
});
|
||||
const toolDefs = data.allowed_tools && data.allowed_tools.length > 0
|
||||
|
||||
@@ -133,6 +133,21 @@ export interface BuildBrainToolsOpts {
|
||||
config: GBrainConfig;
|
||||
/** Optional filter: only include names in this set. */
|
||||
allowedNames?: ReadonlySet<string>;
|
||||
/**
|
||||
* Connected-gbrains brain id (v0.19+, PR 0 plumbing only).
|
||||
*
|
||||
* CURRENT BEHAVIOR: `brainId` is stamped onto each tool-call's
|
||||
* `OperationContext.brainId` for audit / logging, but `ctx.engine` is
|
||||
* still the engine passed in here (the parent job's engine). Ops
|
||||
* targeting mounted brains via brainId WITHOUT a registry lookup will
|
||||
* silently run against the parent engine.
|
||||
*
|
||||
* FUTURE (PR 1): `buildOpContext` will call `BrainRegistry.getBrain
|
||||
* (brainId).engine` to select the right engine per dispatch. Once
|
||||
* wired, `opCtx.engine` will match `opCtx.brainId`. Until then, treat
|
||||
* brainId as metadata only.
|
||||
*/
|
||||
brainId?: string;
|
||||
/**
|
||||
* Trusted-workspace allow-list (v0.23). When set, put_page is bounded
|
||||
* to slugs matching these prefix globs instead of the legacy
|
||||
@@ -149,6 +164,7 @@ interface OpContextDeps {
|
||||
subagentId: number;
|
||||
jobId: number;
|
||||
signal?: AbortSignal;
|
||||
brainId?: string;
|
||||
allowedSlugPrefixes?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -166,6 +182,7 @@ function buildOpContext(deps: OpContextDeps): OperationContext {
|
||||
jobId: deps.jobId,
|
||||
subagentId: deps.subagentId,
|
||||
viaSubagent: true, // FAIL-CLOSED: put_page etc. enforce namespace
|
||||
brainId: deps.brainId,
|
||||
allowedSlugPrefixes: deps.allowedSlugPrefixes
|
||||
? [...deps.allowedSlugPrefixes]
|
||||
: undefined,
|
||||
@@ -209,6 +226,7 @@ export function buildBrainTools(opts: BuildBrainToolsOpts): ToolDef[] {
|
||||
subagentId: opts.subagentId,
|
||||
jobId: ctx.jobId,
|
||||
signal: ctx.signal,
|
||||
brainId: opts.brainId,
|
||||
allowedSlugPrefixes: opts.allowedSlugPrefixes,
|
||||
});
|
||||
const params = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
|
||||
|
||||
@@ -421,6 +421,21 @@ export interface SubagentHandlerData {
|
||||
system?: string;
|
||||
/** Template variables for subagent_def. Arbitrary JSON-serializable. */
|
||||
input_vars?: Record<string, unknown>;
|
||||
/**
|
||||
* Connected-gbrains brain id (v0.19+, PR 0 plumbing only).
|
||||
*
|
||||
* CURRENT BEHAVIOR: stamped onto every tool-call's `OperationContext.
|
||||
* brainId` but NOT yet used to select an engine at dispatch time.
|
||||
* `gbrain agent run` does not yet accept a `--brain` flag that would
|
||||
* populate this field — all subagent jobs submitted by the CLI today
|
||||
* default to the host engine. The field + handler acceptance exist so
|
||||
* PR 1 can add the registry lookup + CLI flag in a single commit.
|
||||
*
|
||||
* FUTURE (PR 1): setting `brain_id: "yc-media"` at job submission will
|
||||
* cause every tool call from the subagent to run against the yc-media
|
||||
* engine via BrainRegistry.getBrain() at buildOpContext time.
|
||||
*/
|
||||
brain_id?: string;
|
||||
/**
|
||||
* Trusted-workspace allow-list for put_page (v0.23 dream cycle).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Mounts-cache composition + publishing (v0.19.0, PR 0).
|
||||
*
|
||||
* The runtime ownership seam (Codex finding #3 from plan-eng-review):
|
||||
* `check-resolvable.ts` VALIDATES RESOLVER.md; it does not DISPATCH skills.
|
||||
* Host agents (Wintermute / OpenClaw / any Claude Code install) read
|
||||
* `skills/RESOLVER.md` directly to route a user request to a skill.
|
||||
*
|
||||
* For mounted team brains to participate in routing without editing the
|
||||
* host's checked-in `skills/RESOLVER.md`, gbrain publishes an aggregated
|
||||
* `~/.gbrain/mounts-cache/RESOLVER.md` + `manifest.json` whenever mounts
|
||||
* change. Host agents are taught (via AGENTS.md / CLAUDE.md install-path
|
||||
* docs) to prefer the aggregated file when it exists.
|
||||
*
|
||||
* This file exposes:
|
||||
* - composeResolvers(...) — pure: merge host + mount RESOLVER entries
|
||||
* - composeManifests(...) — pure: merge host + mount manifest.json entries
|
||||
* - writeMountsCache(...) — writes both aggregated files to disk
|
||||
* - clearMountsCache(...) — removes the cache (used on `mounts remove`
|
||||
* and test teardown)
|
||||
*
|
||||
* Compose functions are PURE: they take inputs, return structured output,
|
||||
* no filesystem writes. Tests can exercise every branch without a tempdir.
|
||||
* Only writeMountsCache touches disk.
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { parseResolverEntries } from './check-resolvable.ts';
|
||||
import { HOST_BRAIN_ID, type MountEntry } from './brain-registry.ts';
|
||||
|
||||
/** Default location of the aggregated cache directory. */
|
||||
function getMountsCacheDir(): string {
|
||||
return join(homedir(), '.gbrain', 'mounts-cache');
|
||||
}
|
||||
|
||||
/** A composed resolver entry with full provenance for audit + dispatch. */
|
||||
export interface ComposedResolverEntry {
|
||||
/** The user-facing trigger phrase from the RESOLVER.md table. */
|
||||
trigger: string;
|
||||
/**
|
||||
* Namespace-qualified skill name. Format:
|
||||
* - 'query' for host skills (no prefix)
|
||||
* - 'yc-media::query' for mount skills
|
||||
* Stable across mount-path changes — only depends on mount id.
|
||||
*/
|
||||
qualifiedName: string;
|
||||
/**
|
||||
* Absolute filesystem path to the SKILL.md. Host agents read this
|
||||
* directly. For host skills, resolved against the host skills dir;
|
||||
* for mount skills, resolved against the mount's clone path.
|
||||
*/
|
||||
absolutePath: string;
|
||||
/** Mount id ('host' for host skills, else the mount id). */
|
||||
brainId: string;
|
||||
/** Section header from RESOLVER.md ('Brain operations', etc.) */
|
||||
section: string;
|
||||
/**
|
||||
* True when this entry represents an external pointer (e.g.
|
||||
* 'GStack: /review', 'Check CLAUDE.md'). Not a filesystem path.
|
||||
*/
|
||||
isExternal: boolean;
|
||||
}
|
||||
|
||||
/** Information about a skill shadowed by a host skill of the same name. */
|
||||
export interface ShadowInfo {
|
||||
skillName: string;
|
||||
hostEntry: ComposedResolverEntry;
|
||||
shadowedMounts: Array<{ mountId: string; absolutePath: string }>;
|
||||
}
|
||||
|
||||
/** Information about a bare name that resolves to two or more mounts. */
|
||||
export interface AmbiguityInfo {
|
||||
/** The short skill name (filename without .SKILL.md) users would type. */
|
||||
skillName: string;
|
||||
/** The mount ids that ALL claim this name (host excluded — host wins). */
|
||||
mountIds: string[];
|
||||
}
|
||||
|
||||
/** Output of composeResolvers — pure, no side effects. */
|
||||
export interface ComposedResolver {
|
||||
entries: ComposedResolverEntry[];
|
||||
shadows: ShadowInfo[];
|
||||
ambiguities: AmbiguityInfo[];
|
||||
}
|
||||
|
||||
/** A single skill declaration in a manifest.json. */
|
||||
export interface ManifestEntry {
|
||||
/** Namespace-qualified name. 'query' or 'yc-media::query'. */
|
||||
name: string;
|
||||
/** Absolute filesystem path to the SKILL.md. */
|
||||
absolutePath: string;
|
||||
/** Mount id ('host' for host skills). */
|
||||
brainId: string;
|
||||
}
|
||||
|
||||
/** Output of composeManifests. */
|
||||
export interface ComposedManifest {
|
||||
entries: ManifestEntry[];
|
||||
}
|
||||
|
||||
/** Default skills-subdir within a mount's clone. */
|
||||
const DEFAULT_SKILLS_SUBDIR = 'skills';
|
||||
|
||||
/** Extract the filename-part of a skills-relative path (e.g. 'query/SKILL.md' → 'query'). */
|
||||
function skillNameFromRelPath(relPath: string): string {
|
||||
// 'skills/query/SKILL.md' → 'query'; 'query/SKILL.md' → 'query'
|
||||
const parts = relPath.split('/');
|
||||
const skillIdx = parts.indexOf('skills');
|
||||
const start = skillIdx >= 0 ? skillIdx + 1 : 0;
|
||||
if (start >= parts.length) return '';
|
||||
return parts[start];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose host + mount RESOLVER.md entries into a single aggregated list
|
||||
* with shadow + ambiguity detection.
|
||||
*
|
||||
* Host skills always win over any mount skill of the same name (locked
|
||||
* decision 1 from plan). When two mounts ship the same skill name and the
|
||||
* host does NOT define it, both entries are emitted with namespace prefixes
|
||||
* and the name is reported in `ambiguities` so doctor can warn.
|
||||
*/
|
||||
export function composeResolvers(
|
||||
hostSkillsDir: string,
|
||||
mounts: MountEntry[],
|
||||
opts: { readFile?: (path: string) => string | null } = {},
|
||||
): ComposedResolver {
|
||||
const readFile = opts.readFile ?? ((p: string) => {
|
||||
try { return existsSync(p) ? readFileSync(p, 'utf-8') : null; } catch { return null; }
|
||||
});
|
||||
|
||||
const hostResolverPath = join(hostSkillsDir, 'RESOLVER.md');
|
||||
const hostContent = readFile(hostResolverPath);
|
||||
const hostRawEntries = hostContent ? parseResolverEntries(hostContent) : [];
|
||||
|
||||
// Host entries: fully qualified against the host skills dir.
|
||||
const hostEntries: ComposedResolverEntry[] = hostRawEntries.map(e => {
|
||||
const isExternal = e.isGStack;
|
||||
const abs = isExternal
|
||||
? e.skillPath
|
||||
: join(hostSkillsDir, e.skillPath.replace(/^skills\//, ''));
|
||||
const name = isExternal ? e.skillPath : skillNameFromRelPath(e.skillPath);
|
||||
return {
|
||||
trigger: e.trigger,
|
||||
qualifiedName: name,
|
||||
absolutePath: abs,
|
||||
brainId: HOST_BRAIN_ID,
|
||||
section: e.section,
|
||||
isExternal,
|
||||
};
|
||||
});
|
||||
|
||||
// Build fast lookup of host skill names (the shadow set).
|
||||
const hostSkillNames = new Set<string>();
|
||||
for (const e of hostEntries) {
|
||||
if (!e.isExternal) hostSkillNames.add(e.qualifiedName);
|
||||
}
|
||||
|
||||
// Track which mount a skill-name came from so we can detect ambiguity.
|
||||
const byNameMountIds = new Map<string, Set<string>>(); // short name → {mount ids}
|
||||
const mountEntriesByMount: Array<{ mount: MountEntry; entries: ComposedResolverEntry[] }> = [];
|
||||
const shadowedByName = new Map<string, Array<{ mountId: string; absolutePath: string }>>();
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.enabled === false) continue;
|
||||
const mountSkillsDir = join(mount.path, DEFAULT_SKILLS_SUBDIR);
|
||||
const resolverPath = join(mountSkillsDir, 'RESOLVER.md');
|
||||
const content = readFile(resolverPath);
|
||||
if (!content) continue; // Mount without a RESOLVER.md contributes no routing entries. Not an error.
|
||||
const rawEntries = parseResolverEntries(content);
|
||||
const composed: ComposedResolverEntry[] = rawEntries.map(e => {
|
||||
const isExternal = e.isGStack;
|
||||
const shortName = isExternal ? e.skillPath : skillNameFromRelPath(e.skillPath);
|
||||
const qualifiedName = isExternal ? shortName : `${mount.id}::${shortName}`;
|
||||
const abs = isExternal
|
||||
? e.skillPath
|
||||
: join(mountSkillsDir, e.skillPath.replace(/^skills\//, ''));
|
||||
return {
|
||||
trigger: e.trigger,
|
||||
qualifiedName,
|
||||
absolutePath: abs,
|
||||
brainId: mount.id,
|
||||
section: e.section,
|
||||
isExternal,
|
||||
};
|
||||
});
|
||||
mountEntriesByMount.push({ mount, entries: composed });
|
||||
|
||||
for (const entry of composed) {
|
||||
if (entry.isExternal) continue;
|
||||
const shortName = entry.qualifiedName.split('::').pop() ?? '';
|
||||
if (!shortName) continue;
|
||||
if (hostSkillNames.has(shortName)) {
|
||||
// Shadow: host wins. Record so doctor can emit a warning.
|
||||
const list = shadowedByName.get(shortName) ?? [];
|
||||
list.push({ mountId: mount.id, absolutePath: entry.absolutePath });
|
||||
shadowedByName.set(shortName, list);
|
||||
continue;
|
||||
}
|
||||
const set = byNameMountIds.get(shortName) ?? new Set();
|
||||
set.add(mount.id);
|
||||
byNameMountIds.set(shortName, set);
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguities: any bare name with 2+ mounts (host excluded).
|
||||
const ambiguities: AmbiguityInfo[] = [];
|
||||
for (const [name, ids] of byNameMountIds.entries()) {
|
||||
if (ids.size >= 2) {
|
||||
ambiguities.push({ skillName: name, mountIds: Array.from(ids).sort() });
|
||||
}
|
||||
}
|
||||
ambiguities.sort((a, b) => a.skillName.localeCompare(b.skillName));
|
||||
|
||||
// Shadows: group by host entry.
|
||||
const shadows: ShadowInfo[] = [];
|
||||
for (const [name, shadowedMounts] of shadowedByName.entries()) {
|
||||
const hostEntry = hostEntries.find(h => h.qualifiedName === name && !h.isExternal);
|
||||
if (!hostEntry) continue;
|
||||
shadows.push({
|
||||
skillName: name,
|
||||
hostEntry,
|
||||
shadowedMounts: shadowedMounts.sort((a, b) => a.mountId.localeCompare(b.mountId)),
|
||||
});
|
||||
}
|
||||
shadows.sort((a, b) => a.skillName.localeCompare(b.skillName));
|
||||
|
||||
// Final entry list: host first, then all mount entries in mount-id order.
|
||||
// Shadow detection applies to BARE-NAME routing only (the host entry wins
|
||||
// when the agent types `ingest`), NOT to namespace-qualified routing
|
||||
// (`yc-media::ingest` must always resolve to the mount, even when shadowed
|
||||
// by a host skill of the same short name). Codex review 2026-04-23 caught
|
||||
// the earlier version that dropped shadowed mount entries — that broke
|
||||
// the entire namespace-disambiguation model.
|
||||
const mountEntries: ComposedResolverEntry[] = [];
|
||||
mountEntriesByMount.sort((a, b) => a.mount.id.localeCompare(b.mount.id));
|
||||
for (const { entries } of mountEntriesByMount) {
|
||||
for (const e of entries) mountEntries.push(e);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: [...hostEntries, ...mountEntries],
|
||||
shadows,
|
||||
ambiguities,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a manifest.json and return its skills[] array. Returns [] when the
|
||||
* file is absent or malformed (propagating the same forgiving semantics as
|
||||
* check-resolvable's loadManifest).
|
||||
*/
|
||||
function readManifestSkills(skillsDir: string, readFile?: (p: string) => string | null): Array<{ name: string; path: string }> {
|
||||
const path = join(skillsDir, 'manifest.json');
|
||||
const reader = readFile ?? ((p: string) => {
|
||||
try { return existsSync(p) ? readFileSync(p, 'utf-8') : null; } catch { return null; }
|
||||
});
|
||||
const content = reader(path);
|
||||
if (!content) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return Array.isArray(parsed.skills) ? parsed.skills : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose host + mount manifest.json entries. Host wins on name
|
||||
* collisions (shadow). Mount entries get namespace-prefixed names.
|
||||
* Codex finding #8: without this, remote skills break doctor conformance.
|
||||
*/
|
||||
export function composeManifests(
|
||||
hostSkillsDir: string,
|
||||
mounts: MountEntry[],
|
||||
opts: { readFile?: (path: string) => string | null } = {},
|
||||
): ComposedManifest {
|
||||
const hostSkills = readManifestSkills(hostSkillsDir, opts.readFile);
|
||||
const hostEntries: ManifestEntry[] = hostSkills.map(s => ({
|
||||
name: s.name,
|
||||
absolutePath: join(hostSkillsDir, s.path),
|
||||
brainId: HOST_BRAIN_ID,
|
||||
}));
|
||||
const hostNames = new Set(hostEntries.map(e => e.name));
|
||||
|
||||
// Mount entries always get their namespace-qualified name regardless of
|
||||
// shadow by a host skill. The namespace form `yc-media::ingest` must stay
|
||||
// routable even when host defines `ingest` (bare-name host-wins only
|
||||
// governs un-namespaced resolution). Codex review caught the earlier
|
||||
// version that silently dropped shadowed mount entries from the manifest,
|
||||
// making the aggregated cache inconsistent with composeResolvers' output.
|
||||
const mountEntries: ManifestEntry[] = [];
|
||||
const seenMounts = [...mounts].sort((a, b) => a.id.localeCompare(b.id));
|
||||
for (const mount of seenMounts) {
|
||||
if (mount.enabled === false) continue;
|
||||
const mountSkillsDir = join(mount.path, DEFAULT_SKILLS_SUBDIR);
|
||||
const skills = readManifestSkills(mountSkillsDir, opts.readFile);
|
||||
for (const s of skills) {
|
||||
mountEntries.push({
|
||||
name: `${mount.id}::${s.name}`,
|
||||
absolutePath: join(mountSkillsDir, s.path),
|
||||
brainId: mount.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
void hostNames; // retained for future shadow metadata (PR 1 doctor warning)
|
||||
|
||||
return { entries: [...hostEntries, ...mountEntries] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the composed resolver as markdown matching the existing
|
||||
* skills/RESOLVER.md format. The table groups by section (preserving
|
||||
* host section headings) with mount entries appended under a new
|
||||
* "Mounted brains" section.
|
||||
*/
|
||||
export function renderResolverMarkdown(composed: ComposedResolver): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('# GBrain Skill Resolver (aggregated)');
|
||||
lines.push('');
|
||||
lines.push('Auto-generated by `gbrain mounts add|remove|sync`. Do not edit by hand.');
|
||||
lines.push('Host agents (Wintermute/OpenClaw/Claude Code) should prefer this file over');
|
||||
lines.push('the repo-checked-in `skills/RESOLVER.md` when it exists.');
|
||||
lines.push('');
|
||||
lines.push('See `docs/architecture/brains-and-sources.md` for the mental model.');
|
||||
lines.push('');
|
||||
|
||||
// Group entries by section in insertion order.
|
||||
const bySection = new Map<string, ComposedResolverEntry[]>();
|
||||
for (const e of composed.entries) {
|
||||
const key = e.section || '(uncategorized)';
|
||||
const bucket = bySection.get(key) ?? [];
|
||||
bucket.push(e);
|
||||
bySection.set(key, bucket);
|
||||
}
|
||||
|
||||
for (const [section, entries] of bySection.entries()) {
|
||||
lines.push(`## ${section}`);
|
||||
lines.push('');
|
||||
lines.push('| Trigger | Skill | Brain |');
|
||||
lines.push('|---------|-------|-------|');
|
||||
for (const e of entries) {
|
||||
const skillCol = e.isExternal ? e.absolutePath : `\`${e.absolutePath}\``;
|
||||
lines.push(`| ${e.trigger} | ${skillCol} | ${e.brainId} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (composed.shadows.length > 0) {
|
||||
lines.push('## Shadows');
|
||||
lines.push('');
|
||||
lines.push('Host skills that shadow mount skills of the same name:');
|
||||
lines.push('');
|
||||
for (const s of composed.shadows) {
|
||||
lines.push(`- \`${s.skillName}\` (host) shadows ${s.shadowedMounts.map(m => `\`${m.mountId}::${s.skillName}\``).join(', ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (composed.ambiguities.length > 0) {
|
||||
lines.push('## Ambiguous bare names');
|
||||
lines.push('');
|
||||
lines.push('These skill names are defined in multiple mounts. Use the explicit');
|
||||
lines.push('namespace form (e.g. `yc-media::ingest`) to disambiguate.');
|
||||
lines.push('');
|
||||
for (const a of composed.ambiguities) {
|
||||
lines.push(`- \`${a.skillName}\` → ${a.mountIds.map(m => `\`${m}::${a.skillName}\``).join(', ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Render the composed manifest as manifest.json bytes. */
|
||||
export function renderManifestJson(composed: ComposedManifest): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
generated_by: 'gbrain mounts',
|
||||
generated_at: new Date().toISOString(),
|
||||
skills: composed.entries.map(e => ({
|
||||
name: e.name,
|
||||
path: e.absolutePath,
|
||||
brain: e.brainId,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the aggregated cache to ~/.gbrain/mounts-cache/. Safe to call
|
||||
* repeatedly. The directory is created if missing. Both files are
|
||||
* rewritten atomically (write-then-rename via a .tmp sibling).
|
||||
*/
|
||||
export function writeMountsCache(
|
||||
hostSkillsDir: string,
|
||||
mounts: MountEntry[],
|
||||
opts: { cacheDir?: string } = {},
|
||||
): { resolverPath: string; manifestPath: string } {
|
||||
const cacheDir = opts.cacheDir ?? getMountsCacheDir();
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const resolver = composeResolvers(hostSkillsDir, mounts);
|
||||
const manifest = composeManifests(hostSkillsDir, mounts);
|
||||
|
||||
const resolverPath = join(cacheDir, 'RESOLVER.md');
|
||||
const manifestPath = join(cacheDir, 'manifest.json');
|
||||
|
||||
// Unique tmp names per call so concurrent `gbrain mounts add|remove`
|
||||
// invocations don't clobber each other's .tmp file. The two-file swap
|
||||
// (RESOLVER.md + manifest.json) is not itself atomic across files —
|
||||
// readers may briefly observe RESOLVER.md(new) + manifest.json(old).
|
||||
// That's acceptable: the aggregated cache is recomputable from
|
||||
// mounts.json at any time, and doctor will flag divergence. A true
|
||||
// generation-swap (cacheDir/current → new-gen dir) is deferred to PR 1.
|
||||
const suffix = `${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
||||
const resolverTmp = `${resolverPath}.tmp.${suffix}`;
|
||||
const manifestTmp = `${manifestPath}.tmp.${suffix}`;
|
||||
|
||||
writeFileSync(resolverTmp, renderResolverMarkdown(resolver), { mode: 0o644 });
|
||||
writeFileSync(manifestTmp, renderManifestJson(manifest), { mode: 0o644 });
|
||||
|
||||
// Atomic swap via rename on each file.
|
||||
renameSync(resolverTmp, resolverPath);
|
||||
renameSync(manifestTmp, manifestPath);
|
||||
|
||||
return { resolverPath, manifestPath };
|
||||
}
|
||||
|
||||
/** Remove the aggregated cache dir. Called by `gbrain mounts remove` and tests. */
|
||||
export function clearMountsCache(opts: { cacheDir?: string } = {}): void {
|
||||
const cacheDir = opts.cacheDir ?? getMountsCacheDir();
|
||||
if (!existsSync(cacheDir)) return;
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __testing = {
|
||||
getMountsCacheDir,
|
||||
skillNameFromRelPath,
|
||||
DEFAULT_SKILLS_SUBDIR,
|
||||
};
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* GBrain OAuth 2.1 Provider — implements MCP SDK's OAuthServerProvider.
|
||||
*
|
||||
* Backed by raw SQL (PGLite or Postgres), not the BrainEngine interface.
|
||||
* OAuth is infrastructure, not brain operations.
|
||||
*
|
||||
* Supports:
|
||||
* - Client registration (manual via CLI or Dynamic Client Registration)
|
||||
* - Authorization code flow with PKCE (for ChatGPT, browser-based clients)
|
||||
* - Client credentials flow (for machine-to-machine: Perplexity, Claude)
|
||||
* - Token refresh with rotation
|
||||
* - Token revocation
|
||||
* - Legacy access_tokens fallback for backward compat
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import type {
|
||||
OAuthClientInformationFull,
|
||||
OAuthTokens,
|
||||
OAuthTokenRevocationRequest,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
|
||||
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import { hashToken, generateToken } from './utils.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Raw SQL query function — works with both PGLite and postgres tagged templates */
|
||||
type SqlQuery = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Record<string, unknown>[]>;
|
||||
|
||||
/**
|
||||
* Convert a JS array to a PostgreSQL array literal for PGLite compat.
|
||||
*
|
||||
* PGLite's `db.query(sql, params)` rejects JS arrays bound directly to TEXT[]
|
||||
* columns ("insufficient data left in message"), so we hand-build the array
|
||||
* literal `{...}` and let Postgres parse it on insert.
|
||||
*
|
||||
* SECURITY: every element is wrapped in double quotes with `"` and `\`
|
||||
* escaped. Without this, an element containing a comma (e.g., a malicious
|
||||
* `redirect_uri` containing `,`) would be parsed by Postgres as MULTIPLE
|
||||
* array elements, smuggling values past validation. See CSO finding #5.
|
||||
*/
|
||||
function pgArray(arr: string[]): string {
|
||||
if (!arr || arr.length === 0) return '{}';
|
||||
const escaped = arr.map(s => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
|
||||
return `{${escaped.join(',')}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a redirect_uri per RFC 6749 §3.1.2.1.
|
||||
*
|
||||
* Production redirect_uris MUST be HTTPS. The only allowed plaintext
|
||||
* exceptions are loopback (127.0.0.1, ::1, localhost) which are unreachable
|
||||
* from the network. Throws a descriptive error on rejection.
|
||||
*
|
||||
* Used by the DCR (Dynamic Client Registration) path; the CLI registration
|
||||
* path trusts the operator and bypasses this gate.
|
||||
*/
|
||||
function validateRedirectUri(uri: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch {
|
||||
throw new Error(`Invalid redirect_uri: not a parseable URL: ${uri}`);
|
||||
}
|
||||
const isLoopback = parsed.hostname === 'localhost'
|
||||
|| parsed.hostname === '127.0.0.1'
|
||||
|| parsed.hostname === '[::1]'
|
||||
|| parsed.hostname === '::1';
|
||||
if (parsed.protocol === 'https:') return;
|
||||
if (parsed.protocol === 'http:' && isLoopback) return;
|
||||
throw new Error(
|
||||
`redirect_uri must use https:// (or http://localhost for loopback): ${uri}`,
|
||||
);
|
||||
}
|
||||
|
||||
interface GBrainOAuthProviderOptions {
|
||||
sql: SqlQuery;
|
||||
/** Default token TTL in seconds (default: 3600 = 1 hour) */
|
||||
tokenTtl?: number;
|
||||
/** Default refresh token TTL in seconds (default: 30 days) */
|
||||
refreshTtl?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clients Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class GBrainClientsStore implements OAuthRegisteredClientsStore {
|
||||
constructor(private sql: SqlQuery) {}
|
||||
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
const rows = await this.sql`
|
||||
SELECT client_id, client_secret_hash, client_name, redirect_uris,
|
||||
grant_types, scope, token_endpoint_auth_method,
|
||||
client_id_issued_at, client_secret_expires_at
|
||||
FROM oauth_clients WHERE client_id = ${clientId}
|
||||
`;
|
||||
if (rows.length === 0) return undefined;
|
||||
const r = rows[0];
|
||||
return {
|
||||
client_id: r.client_id as string,
|
||||
client_secret: r.client_secret_hash as string | undefined,
|
||||
client_name: r.client_name as string,
|
||||
redirect_uris: (r.redirect_uris as string[]) || [],
|
||||
grant_types: (r.grant_types as string[]) || ['client_credentials'],
|
||||
scope: r.scope as string | undefined,
|
||||
token_endpoint_auth_method: r.token_endpoint_auth_method as string | undefined,
|
||||
client_id_issued_at: r.client_id_issued_at as number | undefined,
|
||||
client_secret_expires_at: r.client_secret_expires_at as number | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async registerClient(
|
||||
client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||
): Promise<OAuthClientInformationFull> {
|
||||
// Enforce HTTPS for all redirect_uris on the DCR path (RFC 6749 §3.1.2.1).
|
||||
// Without this, an attacker could register a non-loopback http:// URI and
|
||||
// exfiltrate auth codes over plaintext. CLI registrations bypass this gate
|
||||
// (operators are trusted; they can register http:// for testing).
|
||||
for (const uri of client.redirect_uris || []) {
|
||||
validateRedirectUri(String(uri));
|
||||
}
|
||||
|
||||
const clientId = generateToken('gbrain_cl_');
|
||||
const clientSecret = generateToken('gbrain_cs_');
|
||||
const secretHash = hashToken(clientSecret);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await this.sql`
|
||||
INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris,
|
||||
grant_types, scope, token_endpoint_auth_method,
|
||||
client_id_issued_at)
|
||||
VALUES (${clientId}, ${secretHash}, ${client.client_name || 'unnamed'},
|
||||
${pgArray((client.redirect_uris || []).map(String))},
|
||||
${pgArray(client.grant_types || ['client_credentials'])},
|
||||
${client.scope || ''}, ${client.token_endpoint_auth_method || 'client_secret_post'},
|
||||
${now})
|
||||
`;
|
||||
|
||||
return {
|
||||
...client,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
client_id_issued_at: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
private sql: SqlQuery;
|
||||
private _clientsStore: GBrainClientsStore;
|
||||
private tokenTtl: number;
|
||||
private refreshTtl: number;
|
||||
|
||||
constructor(options: GBrainOAuthProviderOptions) {
|
||||
this.sql = options.sql;
|
||||
this._clientsStore = new GBrainClientsStore(this.sql);
|
||||
this.tokenTtl = options.tokenTtl || 3600;
|
||||
this.refreshTtl = options.refreshTtl || 30 * 24 * 3600;
|
||||
}
|
||||
|
||||
get clientsStore(): OAuthRegisteredClientsStore {
|
||||
return this._clientsStore;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Authorization Code Flow
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async authorize(
|
||||
client: OAuthClientInformationFull,
|
||||
params: AuthorizationParams,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const code = generateToken('gbrain_code_');
|
||||
const codeHash = hashToken(code);
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minute TTL
|
||||
|
||||
await this.sql`
|
||||
INSERT INTO oauth_codes (code_hash, client_id, scopes, code_challenge,
|
||||
code_challenge_method, redirect_uri, state, resource, expires_at)
|
||||
VALUES (${codeHash}, ${client.client_id},
|
||||
${pgArray(params.scopes || [])},
|
||||
${params.codeChallenge}, ${'S256'},
|
||||
${params.redirectUri}, ${params.state || null},
|
||||
${params.resource?.toString() || null}, ${expiresAt})
|
||||
`;
|
||||
|
||||
// Redirect back with the code
|
||||
const redirectUrl = new URL(params.redirectUri);
|
||||
redirectUrl.searchParams.set('code', code);
|
||||
if (params.state) redirectUrl.searchParams.set('state', params.state);
|
||||
res.redirect(redirectUrl.toString());
|
||||
}
|
||||
|
||||
async challengeForAuthorizationCode(
|
||||
_client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
): Promise<string> {
|
||||
const codeHash = hashToken(authorizationCode);
|
||||
const rows = await this.sql`
|
||||
SELECT code_challenge FROM oauth_codes
|
||||
WHERE code_hash = ${codeHash} AND expires_at > ${Math.floor(Date.now() / 1000)}
|
||||
`;
|
||||
if (rows.length === 0) throw new Error('Authorization code not found or expired');
|
||||
return rows[0].code_challenge as string;
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
_codeVerifier?: string,
|
||||
_redirectUri?: string,
|
||||
resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
const codeHash = hashToken(authorizationCode);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Atomic single-use: DELETE...RETURNING in one statement closes the
|
||||
// TOCTOU window. RFC 6749 §10.5 requires auth codes be single-use; the
|
||||
// earlier SELECT-then-DELETE pattern let two concurrent token requests
|
||||
// both pass the SELECT before either ran the DELETE, issuing two valid
|
||||
// token pairs from one code. With RETURNING, the second request gets
|
||||
// zero rows back and fails cleanly. See CSO finding #2.
|
||||
const rows = await this.sql`
|
||||
DELETE FROM oauth_codes
|
||||
WHERE code_hash = ${codeHash} AND expires_at > ${now}
|
||||
RETURNING client_id, scopes, resource
|
||||
`;
|
||||
if (rows.length === 0) throw new Error('Authorization code not found or expired');
|
||||
|
||||
const codeRow = rows[0];
|
||||
if (codeRow.client_id !== client.client_id) throw new Error('Client mismatch');
|
||||
|
||||
// Issue tokens
|
||||
const scopes = (codeRow.scopes as string[]) || [];
|
||||
return this.issueTokens(client.client_id, scopes, resource, true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Refresh Token
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async exchangeRefreshToken(
|
||||
client: OAuthClientInformationFull,
|
||||
refreshToken: string,
|
||||
scopes?: string[],
|
||||
resource?: URL,
|
||||
): Promise<OAuthTokens> {
|
||||
const tokenHash = hashToken(refreshToken);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Atomic rotation: DELETE...RETURNING closes the TOCTOU window. RFC 6749
|
||||
// §10.4 detection of stolen refresh tokens depends on second-use failure;
|
||||
// the earlier SELECT-then-DELETE pattern let attacker + victim both
|
||||
// succeed, defeating that signal. See CSO finding #3.
|
||||
const rows = await this.sql`
|
||||
DELETE FROM oauth_tokens
|
||||
WHERE token_hash = ${tokenHash} AND token_type = 'refresh'
|
||||
RETURNING client_id, scopes, expires_at
|
||||
`;
|
||||
if (rows.length === 0) throw new Error('Refresh token not found');
|
||||
|
||||
const row = rows[0];
|
||||
if (row.client_id !== client.client_id) throw new Error('Client mismatch');
|
||||
if ((row.expires_at as number) < now) throw new Error('Refresh token expired');
|
||||
|
||||
const tokenScopes = scopes || (row.scopes as string[]) || [];
|
||||
return this.issueTokens(client.client_id, tokenScopes, resource, true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Token Verification
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const tokenHash = hashToken(token);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Try OAuth tokens first
|
||||
const oauthRows = await this.sql`
|
||||
SELECT client_id, scopes, expires_at, resource FROM oauth_tokens
|
||||
WHERE token_hash = ${tokenHash} AND token_type = 'access'
|
||||
`;
|
||||
|
||||
if (oauthRows.length > 0) {
|
||||
const row = oauthRows[0];
|
||||
if ((row.expires_at as number) < now) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
return {
|
||||
token,
|
||||
clientId: row.client_id as string,
|
||||
scopes: (row.scopes as string[]) || [],
|
||||
expiresAt: row.expires_at as number,
|
||||
resource: row.resource ? new URL(row.resource as string) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: legacy access_tokens table (backward compat)
|
||||
const legacyRows = await this.sql`
|
||||
SELECT name FROM access_tokens
|
||||
WHERE token_hash = ${tokenHash} AND revoked_at IS NULL
|
||||
`;
|
||||
|
||||
if (legacyRows.length > 0) {
|
||||
// Legacy tokens get full admin access (grandfather in)
|
||||
// Update last_used_at
|
||||
await this.sql`
|
||||
UPDATE access_tokens SET last_used_at = now() WHERE token_hash = ${tokenHash}
|
||||
`;
|
||||
return {
|
||||
token,
|
||||
clientId: legacyRows[0].name as string,
|
||||
scopes: ['read', 'write', 'admin'],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Token Revocation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async revokeToken(
|
||||
_client: OAuthClientInformationFull,
|
||||
request: OAuthTokenRevocationRequest,
|
||||
): Promise<void> {
|
||||
const tokenHash = hashToken(request.token);
|
||||
await this.sql`DELETE FROM oauth_tokens WHERE token_hash = ${tokenHash}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Client Credentials (called by custom handler, not SDK)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async exchangeClientCredentials(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
requestedScope?: string,
|
||||
): Promise<OAuthTokens> {
|
||||
const client = await this._clientsStore.getClient(clientId);
|
||||
if (!client) throw new Error('Client not found');
|
||||
|
||||
// Check grant type first (before verifying secret)
|
||||
const grants = (client.grant_types as string[]) || [];
|
||||
if (!grants.includes('client_credentials')) {
|
||||
throw new Error('Client credentials grant not authorized for this client');
|
||||
}
|
||||
|
||||
// Verify secret
|
||||
const secretHash = hashToken(clientSecret);
|
||||
if (client.client_secret !== secretHash) throw new Error('Invalid client secret');
|
||||
|
||||
// Determine scopes
|
||||
const allowedScopes = (client.scope || '').split(' ').filter(Boolean);
|
||||
const requestedScopes = requestedScope ? requestedScope.split(' ').filter(Boolean) : allowedScopes;
|
||||
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
|
||||
|
||||
// Client credentials: access token only, NO refresh token (RFC 6749 4.4.3)
|
||||
return this.issueTokens(clientId, grantedScopes, undefined, false);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Maintenance
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async sweepExpiredTokens(): Promise<number> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = await this.sql`
|
||||
DELETE FROM oauth_tokens WHERE expires_at < ${now}
|
||||
`;
|
||||
const deletedCodes = await this.sql`
|
||||
DELETE FROM oauth_codes WHERE expires_at < ${now}
|
||||
`;
|
||||
return (result as any).count || 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CLI Registration Helper
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async registerClientManual(
|
||||
name: string,
|
||||
grantTypes: string[],
|
||||
scopes: string,
|
||||
redirectUris: string[] = [],
|
||||
): Promise<{ clientId: string; clientSecret: string }> {
|
||||
const clientId = generateToken('gbrain_cl_');
|
||||
const clientSecret = generateToken('gbrain_cs_');
|
||||
const secretHash = hashToken(clientSecret);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await this.sql`
|
||||
INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris,
|
||||
grant_types, scope, client_id_issued_at)
|
||||
VALUES (${clientId}, ${secretHash}, ${name},
|
||||
${pgArray(redirectUris)}, ${pgArray(grantTypes)}, ${scopes}, ${now})
|
||||
`;
|
||||
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: Issue access + optional refresh tokens
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async issueTokens(
|
||||
clientId: string,
|
||||
scopes: string[],
|
||||
resource: URL | undefined,
|
||||
includeRefresh: boolean,
|
||||
): Promise<OAuthTokens> {
|
||||
const accessToken = generateToken('gbrain_at_');
|
||||
const accessHash = hashToken(accessToken);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const accessExpiry = now + this.tokenTtl;
|
||||
|
||||
await this.sql`
|
||||
INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at, resource)
|
||||
VALUES (${accessHash}, ${'access'}, ${clientId},
|
||||
${pgArray(scopes)}, ${accessExpiry}, ${resource?.toString() || null})
|
||||
`;
|
||||
|
||||
const result: OAuthTokens = {
|
||||
access_token: accessToken,
|
||||
token_type: 'bearer',
|
||||
expires_in: this.tokenTtl,
|
||||
scope: scopes.join(' '),
|
||||
};
|
||||
|
||||
if (includeRefresh) {
|
||||
const refreshToken = generateToken('gbrain_rt_');
|
||||
const refreshHash = hashToken(refreshToken);
|
||||
const refreshExpiry = now + this.refreshTtl;
|
||||
|
||||
await this.sql`
|
||||
INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at, resource)
|
||||
VALUES (${refreshHash}, ${'refresh'}, ${clientId},
|
||||
${pgArray(scopes)}, ${refreshExpiry}, ${resource?.toString() || null})
|
||||
`;
|
||||
|
||||
result.refresh_token = refreshToken;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -179,11 +179,24 @@ export interface Logger {
|
||||
error(msg: string): void;
|
||||
}
|
||||
|
||||
export interface AuthInfo {
|
||||
token: string;
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface OperationContext {
|
||||
engine: BrainEngine;
|
||||
config: GBrainConfig;
|
||||
logger: Logger;
|
||||
dryRun: boolean;
|
||||
/**
|
||||
* OAuth auth info (v0.8+). Present when the caller authenticated via OAuth 2.1
|
||||
* through `gbrain serve --http`. Contains clientId and granted scopes for
|
||||
* per-operation scope enforcement.
|
||||
*/
|
||||
auth?: AuthInfo;
|
||||
/**
|
||||
* True when the caller is remote/untrusted (MCP over stdio/HTTP, or any agent-facing entry point).
|
||||
* False for local CLI invocations by the owner of the machine.
|
||||
@@ -231,6 +244,24 @@ export interface OperationContext {
|
||||
* background work.
|
||||
*/
|
||||
cliOpts?: { quiet: boolean; progressJson: boolean; progressInterval: number };
|
||||
/**
|
||||
* Connected-gbrains brain id (v0.19+). Identifies which brain this op is
|
||||
* targeting. 'host' for the default brain configured in ~/.gbrain/config.json;
|
||||
* otherwise a mount id registered in ~/.gbrain/mounts.json.
|
||||
*
|
||||
* `ctx.engine` is the resolved BrainEngine for this id (populated by
|
||||
* BrainRegistry at dispatch time). `brainId` exists alongside for:
|
||||
* - audit logging (mount-ops JSONL carries the id)
|
||||
* - subagent inheritance (child jobs receive the parent's brainId)
|
||||
* - cross-brain citation prefixes in agent output
|
||||
*
|
||||
* Orthogonal to v0.18.0's source_id, which scopes per-repo WITHIN a brain.
|
||||
* See docs/architecture/brains-and-sources.md for the mental model.
|
||||
*
|
||||
* Omitted = 'host' (pre-v0.19 callers + single-brain deployments keep
|
||||
* working without change).
|
||||
*/
|
||||
brainId?: string;
|
||||
}
|
||||
|
||||
export interface Operation {
|
||||
@@ -239,6 +270,8 @@ export interface Operation {
|
||||
params: Record<string, ParamDef>;
|
||||
handler: (ctx: OperationContext, params: Record<string, unknown>) => Promise<unknown>;
|
||||
mutating?: boolean;
|
||||
scope?: 'read' | 'write' | 'admin';
|
||||
localOnly?: boolean;
|
||||
cliHints?: {
|
||||
name?: string;
|
||||
positional?: string[];
|
||||
@@ -280,6 +313,7 @@ const get_page: Operation = {
|
||||
const tags = await ctx.engine.getTags(page.slug);
|
||||
return { ...page, tags, ...(resolved_slug ? { resolved_slug } : {}) };
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'get', positional: ['slug'] },
|
||||
};
|
||||
|
||||
@@ -291,6 +325,7 @@ const put_page: Operation = {
|
||||
content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
const slug = p.slug as string;
|
||||
|
||||
@@ -586,6 +621,7 @@ const delete_page: Operation = {
|
||||
slug: { type: 'string', required: true },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'delete_page', slug: p.slug };
|
||||
await ctx.engine.deletePage(p.slug as string);
|
||||
@@ -615,6 +651,7 @@ const list_pages: Operation = {
|
||||
updated_at: pg.updated_at,
|
||||
}));
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'list' },
|
||||
};
|
||||
|
||||
@@ -662,6 +699,7 @@ const search: Operation = {
|
||||
|
||||
return results;
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'search', positional: ['query'] },
|
||||
};
|
||||
|
||||
@@ -733,6 +771,7 @@ const query: Operation = {
|
||||
|
||||
return results;
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'query', positional: ['query'] },
|
||||
};
|
||||
|
||||
@@ -746,6 +785,7 @@ const add_tag: Operation = {
|
||||
tag: { type: 'string', required: true },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'add_tag', slug: p.slug, tag: p.tag };
|
||||
await ctx.engine.addTag(p.slug as string, p.tag as string);
|
||||
@@ -762,6 +802,7 @@ const remove_tag: Operation = {
|
||||
tag: { type: 'string', required: true },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'remove_tag', slug: p.slug, tag: p.tag };
|
||||
await ctx.engine.removeTag(p.slug as string, p.tag as string);
|
||||
@@ -779,6 +820,7 @@ const get_tags: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getTags(p.slug as string);
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'tags', positional: ['slug'] },
|
||||
};
|
||||
|
||||
@@ -794,6 +836,7 @@ const add_link: Operation = {
|
||||
context: { type: 'string', description: 'Context for the link' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'add_link', from: p.from, to: p.to };
|
||||
await ctx.engine.addLink(
|
||||
@@ -813,6 +856,7 @@ const remove_link: Operation = {
|
||||
to: { type: 'string', required: true },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'remove_link', from: p.from, to: p.to };
|
||||
await ctx.engine.removeLink(p.from as string, p.to as string);
|
||||
@@ -830,6 +874,7 @@ const get_links: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getLinks(p.slug as string);
|
||||
},
|
||||
scope: 'read',
|
||||
};
|
||||
|
||||
const get_backlinks: Operation = {
|
||||
@@ -841,6 +886,7 @@ const get_backlinks: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getBacklinks(p.slug as string);
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'backlinks', positional: ['slug'] },
|
||||
};
|
||||
|
||||
@@ -879,6 +925,7 @@ const traverse_graph: Operation = {
|
||||
}
|
||||
return ctx.engine.traversePaths(slug, { depth, linkType, direction });
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'graph', positional: ['slug'] },
|
||||
};
|
||||
|
||||
@@ -895,6 +942,7 @@ const add_timeline_entry: Operation = {
|
||||
source: { type: 'string' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug };
|
||||
const date = p.date as string;
|
||||
@@ -933,6 +981,7 @@ const get_timeline: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getTimeline(p.slug as string);
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'timeline', positional: ['slug'] },
|
||||
};
|
||||
|
||||
@@ -945,6 +994,7 @@ const get_stats: Operation = {
|
||||
handler: async (ctx) => {
|
||||
return ctx.engine.getStats();
|
||||
},
|
||||
scope: 'admin',
|
||||
cliHints: { name: 'stats' },
|
||||
};
|
||||
|
||||
@@ -955,6 +1005,7 @@ const get_health: Operation = {
|
||||
handler: async (ctx) => {
|
||||
return ctx.engine.getHealth();
|
||||
},
|
||||
scope: 'admin',
|
||||
cliHints: { name: 'health' },
|
||||
};
|
||||
|
||||
@@ -967,6 +1018,7 @@ const get_versions: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getVersions(p.slug as string);
|
||||
},
|
||||
scope: 'read',
|
||||
cliHints: { name: 'history', positional: ['slug'] },
|
||||
};
|
||||
|
||||
@@ -978,6 +1030,7 @@ const revert_version: Operation = {
|
||||
version_id: { type: 'number', required: true },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'revert_version', slug: p.slug, version_id: p.version_id };
|
||||
await ctx.engine.createVersion(p.slug as string);
|
||||
@@ -1000,6 +1053,8 @@ const sync_brain: Operation = {
|
||||
no_embed: { type: 'boolean', description: 'Skip embedding generation' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'admin',
|
||||
localOnly: true,
|
||||
handler: async (ctx, p) => {
|
||||
const { performSync } = await import('../commands/sync.ts');
|
||||
return performSync(ctx.engine, {
|
||||
@@ -1024,6 +1079,7 @@ const put_raw_data: Operation = {
|
||||
data: { type: 'object', required: true, description: 'Raw data object' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'put_raw_data', slug: p.slug, source: p.source };
|
||||
await ctx.engine.putRawData(p.slug as string, p.source as string, p.data as object);
|
||||
@@ -1041,6 +1097,7 @@ const get_raw_data: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined);
|
||||
},
|
||||
scope: 'read',
|
||||
};
|
||||
|
||||
// --- Resolution & Chunks ---
|
||||
@@ -1054,6 +1111,7 @@ const resolve_slugs: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.resolveSlugs(p.partial as string);
|
||||
},
|
||||
scope: 'read',
|
||||
};
|
||||
|
||||
const get_chunks: Operation = {
|
||||
@@ -1065,6 +1123,7 @@ const get_chunks: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getChunks(p.slug as string);
|
||||
},
|
||||
scope: 'read',
|
||||
};
|
||||
|
||||
// --- Ingest Log ---
|
||||
@@ -1079,6 +1138,7 @@ const log_ingest: Operation = {
|
||||
summary: { type: 'string', required: true },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'write',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'log_ingest' };
|
||||
await ctx.engine.logIngest({
|
||||
@@ -1100,6 +1160,7 @@ const get_ingest_log: Operation = {
|
||||
handler: async (ctx, p) => {
|
||||
return ctx.engine.getIngestLog({ limit: clampSearchLimit(p.limit as number | undefined, 20, 50) });
|
||||
},
|
||||
scope: 'read',
|
||||
};
|
||||
|
||||
// --- File Operations ---
|
||||
@@ -1115,6 +1176,8 @@ const file_list: Operation = {
|
||||
params: {
|
||||
slug: { type: 'string', description: 'Filter by page slug' },
|
||||
},
|
||||
scope: 'admin',
|
||||
localOnly: true,
|
||||
handler: async (_ctx, p) => {
|
||||
const sql = db.getConnection();
|
||||
const slug = p.slug as string | undefined;
|
||||
@@ -1133,6 +1196,8 @@ const file_upload: Operation = {
|
||||
page_slug: { type: 'string', description: 'Associate with page' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'admin',
|
||||
localOnly: true,
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'file_upload', path: p.path };
|
||||
|
||||
@@ -1213,6 +1278,8 @@ const file_url: Operation = {
|
||||
params: {
|
||||
storage_path: { type: 'string', required: true },
|
||||
},
|
||||
scope: 'admin',
|
||||
localOnly: true,
|
||||
handler: async (_ctx, p) => {
|
||||
const sql = db.getConnection();
|
||||
const rows = await sql`SELECT storage_path, mime_type, size_bytes FROM files WHERE storage_path = ${p.storage_path as string}`;
|
||||
@@ -1239,6 +1306,7 @@ const submit_job: Operation = {
|
||||
timeout_ms: { type: 'number', description: 'Per-job wall-clock timeout in ms; aborted job goes to dead' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
const name = typeof p.name === 'string' ? p.name.trim() : '';
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'submit_job', name };
|
||||
@@ -1275,6 +1343,7 @@ const get_job: Operation = {
|
||||
params: {
|
||||
id: { type: 'number', required: true, description: 'Job ID' },
|
||||
},
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
const queue = new MinionQueue(ctx.engine);
|
||||
@@ -1293,6 +1362,7 @@ const list_jobs: Operation = {
|
||||
name: { type: 'string', description: 'Filter by job type' },
|
||||
limit: { type: 'number', description: 'Max results (default: 50)' },
|
||||
},
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
const queue = new MinionQueue(ctx.engine);
|
||||
@@ -1312,6 +1382,7 @@ const cancel_job: Operation = {
|
||||
id: { type: 'number', required: true, description: 'Job ID' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'cancel_job', id: p.id };
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
@@ -1329,6 +1400,7 @@ const retry_job: Operation = {
|
||||
id: { type: 'number', required: true, description: 'Job ID' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'retry_job', id: p.id };
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
@@ -1345,6 +1417,7 @@ const get_job_progress: Operation = {
|
||||
params: {
|
||||
id: { type: 'number', required: true, description: 'Job ID' },
|
||||
},
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
const queue = new MinionQueue(ctx.engine);
|
||||
@@ -1360,6 +1433,7 @@ const pause_job: Operation = {
|
||||
params: {
|
||||
id: { type: 'number', required: true, description: 'Job ID' },
|
||||
},
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
const queue = new MinionQueue(ctx.engine);
|
||||
@@ -1375,6 +1449,7 @@ const resume_job: Operation = {
|
||||
params: {
|
||||
id: { type: 'number', required: true, description: 'Job ID' },
|
||||
},
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
const queue = new MinionQueue(ctx.engine);
|
||||
@@ -1391,6 +1466,7 @@ const replay_job: Operation = {
|
||||
id: { type: 'number', required: true, description: 'Source job ID to replay' },
|
||||
data_overrides: { type: 'object', required: false, description: 'Data fields to override (merged with original)' },
|
||||
},
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'replay_job', id: p.id };
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
@@ -1409,6 +1485,7 @@ const send_job_message: Operation = {
|
||||
payload: { type: 'object', required: true, description: 'Message payload (arbitrary JSON)' },
|
||||
sender: { type: 'string', required: false, description: 'Sender identity (default: admin)' },
|
||||
},
|
||||
scope: 'admin',
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.dryRun) return { dry_run: true, action: 'send_job_message', id: p.id };
|
||||
const { MinionQueue } = await import('./minions/queue.ts');
|
||||
@@ -1430,6 +1507,7 @@ const find_orphans: Operation = {
|
||||
description: 'Include auto-generated and pseudo pages (default: false)',
|
||||
},
|
||||
},
|
||||
scope: 'read',
|
||||
handler: async (ctx, p) => {
|
||||
const { findOrphans } = await import('../commands/orphans.ts');
|
||||
return findOrphans(ctx.engine, { includePseudo: (p.include_pseudo as boolean) || false });
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
*
|
||||
* Differences from Postgres:
|
||||
* - No RLS block (no role system in embedded PGLite)
|
||||
* - No access_tokens / mcp_request_log (local-only, no remote auth)
|
||||
* - No files table (file attachments require Supabase Storage)
|
||||
* - No pg_advisory_lock (single connection)
|
||||
*
|
||||
* Includes OAuth tables (oauth_clients, oauth_tokens, oauth_codes) and
|
||||
* auth infrastructure (access_tokens, mcp_request_log) because
|
||||
* `gbrain serve --http` makes PGLite network-accessible.
|
||||
*
|
||||
* Everything else is identical: same tables, triggers, indexes, pgvector HNSW, tsvector GIN.
|
||||
*
|
||||
* DRIFT WARNING: When schema-embedded.ts changes, update this file to match.
|
||||
@@ -392,6 +395,77 @@ CREATE TABLE IF NOT EXISTS eval_capture_failures (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_eval_capture_failures_ts ON eval_capture_failures(ts DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- access_tokens: legacy bearer tokens for remote MCP access
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS access_tokens (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- mcp_request_log: usage logging for MCP requests
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcp_request_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_name TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_log_time_agent ON mcp_request_log(created_at, token_name);
|
||||
|
||||
-- ============================================================
|
||||
-- OAuth 2.1: clients, tokens, authorization codes
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_secret_hash TEXT,
|
||||
client_name TEXT NOT NULL,
|
||||
redirect_uris TEXT[],
|
||||
grant_types TEXT[] DEFAULT '{client_credentials}',
|
||||
scope TEXT,
|
||||
token_endpoint_auth_method TEXT,
|
||||
client_id_issued_at BIGINT,
|
||||
client_secret_expires_at BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
token_type TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
expires_at BIGINT,
|
||||
resource TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_expiry ON oauth_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_client ON oauth_tokens(client_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
|
||||
redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
resource TEXT,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- Trigger-based search_vector (spans pages + timeline_entries)
|
||||
-- ============================================================
|
||||
|
||||
@@ -352,6 +352,51 @@ CREATE TABLE IF NOT EXISTS mcp_request_log (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- OAuth 2.1: clients, tokens, authorization codes
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_secret_hash TEXT,
|
||||
client_name TEXT NOT NULL,
|
||||
redirect_uris TEXT[],
|
||||
grant_types TEXT[] DEFAULT '{"client_credentials"}',
|
||||
scope TEXT,
|
||||
token_endpoint_auth_method TEXT,
|
||||
client_id_issued_at BIGINT,
|
||||
client_secret_expires_at BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
token_type TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
expires_at BIGINT,
|
||||
resource TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_expiry ON oauth_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_client ON oauth_tokens(client_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
|
||||
redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
resource TEXT,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Composite index for admin dashboard request log queries
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_log_time_agent ON mcp_request_log(created_at, token_name);
|
||||
|
||||
-- ============================================================
|
||||
-- files: binary attachments stored in Supabase Storage
|
||||
-- ============================================================
|
||||
@@ -718,6 +763,10 @@ BEGIN
|
||||
ALTER TABLE dream_verdicts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE eval_candidates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE eval_capture_failures ENABLE ROW LEVEL SECURITY;
|
||||
-- v0.26 OAuth 2.1 tables
|
||||
ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE oauth_codes ENABLE ROW LEVEL SECURITY;
|
||||
RAISE NOTICE 'RLS enabled on all tables (role % has BYPASSRLS)', current_user;
|
||||
ELSE
|
||||
RAISE WARNING 'Skipping RLS: role % does not have BYPASSRLS privilege. Run as postgres role to enable.', current_user;
|
||||
|
||||
+15
-1
@@ -1,6 +1,20 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import type { Page, PageInput, PageType, Chunk, SearchResult } from './types.ts';
|
||||
|
||||
/**
|
||||
* SHA-256 hash a token/secret for storage. Never store plaintext tokens.
|
||||
*/
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random token with a prefix.
|
||||
*/
|
||||
export function generateToken(prefix: string): string {
|
||||
return `${prefix}${randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a slug. Slugs are lowercased repo-relative paths.
|
||||
* Rejects empty slugs, path traversal (..), and leading /.
|
||||
|
||||
@@ -348,6 +348,51 @@ CREATE TABLE IF NOT EXISTS mcp_request_log (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- OAuth 2.1: clients, tokens, authorization codes
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_secret_hash TEXT,
|
||||
client_name TEXT NOT NULL,
|
||||
redirect_uris TEXT[],
|
||||
grant_types TEXT[] DEFAULT '{"client_credentials"}',
|
||||
scope TEXT,
|
||||
token_endpoint_auth_method TEXT,
|
||||
client_id_issued_at BIGINT,
|
||||
client_secret_expires_at BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
token_type TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
expires_at BIGINT,
|
||||
resource TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_expiry ON oauth_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_client ON oauth_tokens(client_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_codes (
|
||||
code_hash TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
||||
scopes TEXT[],
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
|
||||
redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
resource TEXT,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Composite index for admin dashboard request log queries
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_log_time_agent ON mcp_request_log(created_at, token_name);
|
||||
|
||||
-- ============================================================
|
||||
-- files: binary attachments stored in Supabase Storage
|
||||
-- ============================================================
|
||||
@@ -714,6 +759,10 @@ BEGIN
|
||||
ALTER TABLE dream_verdicts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE eval_candidates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE eval_capture_failures ENABLE ROW LEVEL SECURITY;
|
||||
-- v0.26 OAuth 2.1 tables
|
||||
ALTER TABLE oauth_clients ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE oauth_codes ENABLE ROW LEVEL SECURITY;
|
||||
RAISE NOTICE 'RLS enabled on all tables (role % has BYPASSRLS)', current_user;
|
||||
ELSE
|
||||
RAISE WARNING 'Skipping RLS: role % does not have BYPASSRLS privilege. Run as postgres role to enable.', current_user;
|
||||
|
||||
@@ -27,10 +27,10 @@ beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
loadMounts,
|
||||
validateMountId,
|
||||
BrainRegistry,
|
||||
DuplicateMountPathError,
|
||||
UnknownBrainError,
|
||||
HOST_BRAIN_ID,
|
||||
type MountsFile,
|
||||
type MountEntry,
|
||||
} from '../src/core/brain-registry.ts';
|
||||
import { GBrainError } from '../src/core/types.ts';
|
||||
|
||||
/** Create a temp dir + write a mounts.json into it. Returns the path. */
|
||||
function tempMountsFile(contents: unknown): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'brain-registry-'));
|
||||
const path = join(dir, 'mounts.json');
|
||||
writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents));
|
||||
return path;
|
||||
}
|
||||
|
||||
const toCleanup: string[] = [];
|
||||
function track(p: string): string {
|
||||
toCleanup.push(p);
|
||||
return p;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (toCleanup.length > 0) {
|
||||
const p = toCleanup.pop();
|
||||
if (!p) continue;
|
||||
try {
|
||||
rmSync(p, { recursive: true, force: true });
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
describe('validateMountId', () => {
|
||||
test('accepts kebab-case ids', () => {
|
||||
expect(validateMountId('yc-media')).toBe('yc-media');
|
||||
expect(validateMountId('garrys-list')).toBe('garrys-list');
|
||||
expect(validateMountId('a')).toBe('a');
|
||||
expect(validateMountId('yc1')).toBe('yc1');
|
||||
});
|
||||
|
||||
test('rejects empty / non-string', () => {
|
||||
expect(() => validateMountId('')).toThrow(GBrainError);
|
||||
expect(() => validateMountId(null as unknown as string)).toThrow(GBrainError);
|
||||
expect(() => validateMountId(undefined as unknown as string)).toThrow(GBrainError);
|
||||
expect(() => validateMountId(42 as unknown as string)).toThrow(GBrainError);
|
||||
});
|
||||
|
||||
test('rejects reserved host id', () => {
|
||||
expect(() => validateMountId('host')).toThrow(/Reserved/);
|
||||
});
|
||||
|
||||
test('rejects invalid patterns', () => {
|
||||
expect(() => validateMountId('UPPER')).toThrow();
|
||||
expect(() => validateMountId('has space')).toThrow();
|
||||
expect(() => validateMountId('-leading')).toThrow();
|
||||
expect(() => validateMountId('trailing-')).toThrow();
|
||||
expect(() => validateMountId('has_underscore')).toThrow();
|
||||
expect(() => validateMountId('a'.repeat(33))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMounts — file-level parsing', () => {
|
||||
test('returns empty array when mounts.json is absent', () => {
|
||||
const path = join(tmpdir(), `nonexistent-${Date.now()}.json`);
|
||||
expect(loadMounts(path)).toEqual([]);
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', () => {
|
||||
const path = track(tempMountsFile('{ not valid json'));
|
||||
expect(() => loadMounts(path)).toThrow(/Malformed mounts.json/);
|
||||
});
|
||||
|
||||
test('throws on unsupported version', () => {
|
||||
const path = track(tempMountsFile({ version: 99, mounts: [] }));
|
||||
expect(() => loadMounts(path)).toThrow(/Unsupported mounts.json version: 99/);
|
||||
});
|
||||
|
||||
test('throws when mounts is not an array', () => {
|
||||
const path = track(tempMountsFile({ version: 1, mounts: 'not-an-array' }));
|
||||
expect(() => loadMounts(path)).toThrow(/must be an array/);
|
||||
});
|
||||
|
||||
test('throws when top-level is not an object', () => {
|
||||
const path = track(tempMountsFile([1, 2, 3]));
|
||||
expect(() => loadMounts(path)).toThrow(/must be a JSON object/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMounts — entry validation', () => {
|
||||
test('accepts a minimal pglite entry', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1,
|
||||
mounts: [{ id: 'yc-media', path: '/tmp/yc-media', engine: 'pglite', database_path: '/tmp/yc-media/.pg' }],
|
||||
}));
|
||||
const mounts = loadMounts(path);
|
||||
expect(mounts).toHaveLength(1);
|
||||
expect(mounts[0].id).toBe('yc-media');
|
||||
expect(mounts[0].engine).toBe('pglite');
|
||||
expect(mounts[0].enabled).toBe(true); // default
|
||||
});
|
||||
|
||||
test('accepts a postgres entry', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1,
|
||||
mounts: [{
|
||||
id: 'yc-politics', path: '/tmp/yc-politics', engine: 'postgres',
|
||||
database_url: 'postgresql://localhost/luther',
|
||||
}],
|
||||
}));
|
||||
const mounts = loadMounts(path);
|
||||
expect(mounts[0].database_url).toBe('postgresql://localhost/luther');
|
||||
});
|
||||
|
||||
test('resolves paths to absolute form', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1,
|
||||
mounts: [{ id: 'a', path: '/tmp/relative-test', engine: 'pglite', database_path: '/tmp/a/.pg' }],
|
||||
}));
|
||||
const mounts = loadMounts(path);
|
||||
expect(mounts[0].path.startsWith('/')).toBe(true);
|
||||
});
|
||||
|
||||
test('enabled=false is preserved', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1,
|
||||
mounts: [{
|
||||
id: 'disabled-mount', path: '/tmp/disabled', engine: 'pglite',
|
||||
database_path: '/tmp/disabled/.pg', enabled: false,
|
||||
}],
|
||||
}));
|
||||
const mounts = loadMounts(path);
|
||||
expect(mounts[0].enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects duplicate ids', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1,
|
||||
mounts: [
|
||||
{ id: 'dup', path: '/tmp/a', engine: 'pglite', database_path: '/tmp/a/.pg' },
|
||||
{ id: 'dup', path: '/tmp/b', engine: 'pglite', database_path: '/tmp/b/.pg' },
|
||||
],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(/duplicate id "dup"/);
|
||||
});
|
||||
|
||||
test('rejects duplicate paths (Codex finding #9)', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1,
|
||||
mounts: [
|
||||
{ id: 'first', path: '/tmp/shared', engine: 'pglite', database_path: '/tmp/shared/.pg' },
|
||||
{ id: 'second', path: '/tmp/shared', engine: 'pglite', database_path: '/tmp/shared/.pg2' },
|
||||
],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(DuplicateMountPathError);
|
||||
});
|
||||
|
||||
test('rejects entry missing id', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1, mounts: [{ path: '/tmp/x', engine: 'pglite', database_path: '/tmp/x/.pg' }],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(/Invalid mounts\[0\].id/);
|
||||
});
|
||||
|
||||
test('rejects entry missing path', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1, mounts: [{ id: 'no-path', engine: 'pglite', database_path: '/tmp/x/.pg' }],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(/path is required/);
|
||||
});
|
||||
|
||||
test('rejects invalid engine kind', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1, mounts: [{ id: 'bad', path: '/tmp/b', engine: 'sqlite' }],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(/engine must be "postgres" or "pglite"/);
|
||||
});
|
||||
|
||||
test('rejects postgres without database_url', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1, mounts: [{ id: 'pg-no-url', path: '/tmp/p', engine: 'postgres' }],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(/postgres mount requires database_url/);
|
||||
});
|
||||
|
||||
test('rejects pglite without database_path or url', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1, mounts: [{ id: 'pgl-no-path', path: '/tmp/p', engine: 'pglite' }],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(/pglite mount requires database_path/);
|
||||
});
|
||||
|
||||
test('rejects reserved host id', () => {
|
||||
const path = track(tempMountsFile({
|
||||
version: 1, mounts: [{ id: 'host', path: '/tmp/h', engine: 'pglite', database_path: '/tmp/h/.pg' }],
|
||||
}));
|
||||
expect(() => loadMounts(path)).toThrow(/Reserved/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BrainRegistry — resolution', () => {
|
||||
test('listBrainIds includes host + enabled mounts only', () => {
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'a', path: '/tmp/a', engine: 'pglite', database_path: '/tmp/a/.pg', enabled: true },
|
||||
{ id: 'b', path: '/tmp/b', engine: 'pglite', database_path: '/tmp/b/.pg', enabled: false },
|
||||
{ id: 'c', path: '/tmp/c', engine: 'pglite', database_path: '/tmp/c/.pg', enabled: true },
|
||||
];
|
||||
const reg = new BrainRegistry(mounts);
|
||||
expect(reg.listBrainIds()).toEqual([HOST_BRAIN_ID, 'a', 'c']);
|
||||
});
|
||||
|
||||
test('disabled mount → UnknownBrainError', async () => {
|
||||
const reg = new BrainRegistry([
|
||||
{ id: 'disabled', path: '/tmp/d', engine: 'pglite', database_path: '/tmp/d/.pg', enabled: false },
|
||||
]);
|
||||
await expect(reg.getBrain('disabled')).rejects.toBeInstanceOf(UnknownBrainError);
|
||||
});
|
||||
|
||||
test('unknown id → UnknownBrainError with available list', async () => {
|
||||
const reg = new BrainRegistry([
|
||||
{ id: 'yc-media', path: '/tmp/m', engine: 'pglite', database_path: '/tmp/m/.pg', enabled: true },
|
||||
]);
|
||||
try {
|
||||
await reg.getBrain('nonexistent');
|
||||
throw new Error('expected throw');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(UnknownBrainError);
|
||||
if (e instanceof GBrainError) {
|
||||
expect(e.cause_description).toContain('yc-media');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('listMounts returns only enabled mounts', () => {
|
||||
const reg = new BrainRegistry([
|
||||
{ id: 'on', path: '/tmp/on', engine: 'pglite', database_path: '/tmp/on/.pg', enabled: true },
|
||||
{ id: 'off', path: '/tmp/off', engine: 'pglite', database_path: '/tmp/off/.pg', enabled: false },
|
||||
]);
|
||||
const listed = reg.listMounts();
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].id).toBe('on');
|
||||
});
|
||||
|
||||
test('disconnectAll on empty registry is idempotent', async () => {
|
||||
const reg = new BrainRegistry([]);
|
||||
await reg.disconnectAll();
|
||||
await reg.disconnectAll(); // second call must not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('BrainRegistry — lazy init', () => {
|
||||
test('getBrain does not connect until called', () => {
|
||||
const reg = new BrainRegistry([
|
||||
{ id: 'lazy', path: '/tmp/lazy', engine: 'pglite', database_path: '/tmp/lazy/.pg', enabled: true },
|
||||
]);
|
||||
// No assertion on engine state: we just prove the constructor returned
|
||||
// without attempting to touch the filesystem. If init were eager, the
|
||||
// constructor would throw on the missing database_path.
|
||||
expect(reg.listBrainIds()).toContain('lazy');
|
||||
});
|
||||
|
||||
test('empty/null/undefined id routes to host', async () => {
|
||||
// We can't actually call getBrain('') without a host config, so we just
|
||||
// verify the routing logic by observing the default-branch path. This
|
||||
// test proves the fall-through to HOST_BRAIN_ID happens before any
|
||||
// lookup, not that host init actually succeeds.
|
||||
const reg = new BrainRegistry([]);
|
||||
// Expect the host-init path to be attempted (it'll fail on missing
|
||||
// ~/.gbrain/config.json in test env, but the error will come from
|
||||
// initHostBrain, not UnknownBrainError — proving routing hit host).
|
||||
await expect(reg.getBrain(null)).rejects.not.toBeInstanceOf(UnknownBrainError);
|
||||
await expect(reg.getBrain(undefined)).rejects.not.toBeInstanceOf(UnknownBrainError);
|
||||
await expect(reg.getBrain('')).rejects.not.toBeInstanceOf(UnknownBrainError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, test, expect, afterEach, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { resolveBrainId, __testing } from '../src/core/brain-resolver.ts';
|
||||
import { HOST_BRAIN_ID, type MountEntry } from '../src/core/brain-registry.ts';
|
||||
|
||||
const toCleanup: string[] = [];
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
function mktmp(prefix = 'brain-resolver-'): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
toCleanup.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear relevant env so tests don't leak.
|
||||
delete process.env.GBRAIN_BRAIN_ID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
while (toCleanup.length > 0) {
|
||||
const p = toCleanup.pop();
|
||||
if (!p) continue;
|
||||
try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
function noMounts(): MountEntry[] { return []; }
|
||||
|
||||
describe('resolveBrainId — priority order', () => {
|
||||
test('explicit flag beats everything else', () => {
|
||||
process.env.GBRAIN_BRAIN_ID = 'from-env';
|
||||
const dir = mktmp();
|
||||
writeFileSync(join(dir, '.gbrain-mount'), 'from-dotfile\n');
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'from-path', path: dir, engine: 'pglite', database_path: `${dir}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId('explicit-wins', dir, () => mounts)).toBe('explicit-wins');
|
||||
});
|
||||
|
||||
test('env var beats dotfile + path', () => {
|
||||
process.env.GBRAIN_BRAIN_ID = 'from-env';
|
||||
const dir = mktmp();
|
||||
writeFileSync(join(dir, '.gbrain-mount'), 'from-dotfile\n');
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'from-path', path: dir, engine: 'pglite', database_path: `${dir}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, dir, () => mounts)).toBe('from-env');
|
||||
});
|
||||
|
||||
test('dotfile beats path-prefix', () => {
|
||||
const dir = mktmp();
|
||||
writeFileSync(join(dir, '.gbrain-mount'), 'from-dotfile\n');
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'from-path', path: dir, engine: 'pglite', database_path: `${dir}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, dir, () => mounts)).toBe('from-dotfile');
|
||||
});
|
||||
|
||||
test('path-prefix match used when no higher-priority signal', () => {
|
||||
const dir = mktmp();
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'yc-media', path: dir, engine: 'pglite', database_path: `${dir}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, dir, () => mounts)).toBe('yc-media');
|
||||
});
|
||||
|
||||
test('falls back to host when no signal present', () => {
|
||||
const dir = mktmp();
|
||||
expect(resolveBrainId(null, dir, noMounts)).toBe(HOST_BRAIN_ID);
|
||||
expect(resolveBrainId(undefined, dir, noMounts)).toBe(HOST_BRAIN_ID);
|
||||
expect(resolveBrainId('', dir, noMounts)).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBrainId — dotfile behavior', () => {
|
||||
test('walks up to find .gbrain-mount in ancestor', () => {
|
||||
const parent = mktmp();
|
||||
const nested = join(parent, 'deep/nested/dir');
|
||||
mkdirSync(nested, { recursive: true });
|
||||
writeFileSync(join(parent, '.gbrain-mount'), 'luther\n');
|
||||
expect(resolveBrainId(null, nested, noMounts)).toBe('luther');
|
||||
});
|
||||
|
||||
test('skips malformed dotfile, keeps walking', () => {
|
||||
const grandparent = mktmp();
|
||||
const parent = join(grandparent, 'mid');
|
||||
const child = join(parent, 'deep');
|
||||
mkdirSync(child, { recursive: true });
|
||||
writeFileSync(join(parent, '.gbrain-mount'), '!!!invalid!!!\n');
|
||||
writeFileSync(join(grandparent, '.gbrain-mount'), 'valid-id\n');
|
||||
expect(resolveBrainId(null, child, noMounts)).toBe('valid-id');
|
||||
});
|
||||
|
||||
test('accepts "host" in dotfile (explicit opt-out of mount routing)', () => {
|
||||
const dir = mktmp();
|
||||
writeFileSync(join(dir, '.gbrain-mount'), `${HOST_BRAIN_ID}\n`);
|
||||
// Even with a mount whose path contains cwd, dotfile wins and picks host.
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'would-match', path: dir, engine: 'pglite', database_path: `${dir}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, dir, () => mounts)).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
|
||||
test('trims whitespace + only uses first line', () => {
|
||||
const dir = mktmp();
|
||||
writeFileSync(join(dir, '.gbrain-mount'), ' yc-media \n# comment line\n');
|
||||
expect(resolveBrainId(null, dir, noMounts)).toBe('yc-media');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBrainId — path-prefix match', () => {
|
||||
test('longest-prefix wins for nested mounts', () => {
|
||||
const parent = mktmp();
|
||||
const child = join(parent, 'child');
|
||||
mkdirSync(child, { recursive: true });
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'outer', path: parent, engine: 'pglite', database_path: `${parent}/.pg`, enabled: true },
|
||||
{ id: 'inner', path: child, engine: 'pglite', database_path: `${child}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, child, () => mounts)).toBe('inner');
|
||||
});
|
||||
|
||||
test('disabled mount is ignored', () => {
|
||||
const dir = mktmp();
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'off', path: dir, engine: 'pglite', database_path: `${dir}/.pg`, enabled: false },
|
||||
];
|
||||
expect(resolveBrainId(null, dir, () => mounts)).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
|
||||
test('sibling directory does not match', () => {
|
||||
const parent = mktmp();
|
||||
const a = join(parent, 'a');
|
||||
const b = join(parent, 'b');
|
||||
mkdirSync(a, { recursive: true });
|
||||
mkdirSync(b, { recursive: true });
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'a-mount', path: a, engine: 'pglite', database_path: `${a}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, b, () => mounts)).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
|
||||
test('exact path match works', () => {
|
||||
const dir = mktmp();
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'exact', path: dir, engine: 'pglite', database_path: `${dir}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, dir, () => mounts)).toBe('exact');
|
||||
});
|
||||
|
||||
test('prefix-without-separator does NOT match (no false positive)', () => {
|
||||
// Ensure /tmp/foo does NOT match /tmp/foobar. Resolver must require / boundary.
|
||||
const parent = mktmp();
|
||||
const foo = join(parent, 'foo');
|
||||
const foobar = join(parent, 'foobar');
|
||||
mkdirSync(foo, { recursive: true });
|
||||
mkdirSync(foobar, { recursive: true });
|
||||
const mounts: MountEntry[] = [
|
||||
{ id: 'foo', path: foo, engine: 'pglite', database_path: `${foo}/.pg`, enabled: true },
|
||||
];
|
||||
expect(resolveBrainId(null, foobar, () => mounts)).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBrainId — validation', () => {
|
||||
test('invalid --brain value throws', () => {
|
||||
expect(() => resolveBrainId('UPPERCASE', '/tmp', noMounts)).toThrow();
|
||||
expect(() => resolveBrainId('has space', '/tmp', noMounts)).toThrow();
|
||||
expect(() => resolveBrainId('-leading', '/tmp', noMounts)).toThrow();
|
||||
});
|
||||
|
||||
test('invalid GBRAIN_BRAIN_ID value throws', () => {
|
||||
process.env.GBRAIN_BRAIN_ID = 'UPPER';
|
||||
expect(() => resolveBrainId(null, '/tmp', noMounts)).toThrow();
|
||||
});
|
||||
|
||||
test('mounts loader failure falls through to host (no crash)', () => {
|
||||
const badLoader = () => { throw new Error('mounts.json exploded'); };
|
||||
// Should NOT throw: resolver swallows loader failures and returns host.
|
||||
// Any downstream brain registry call will surface the real error.
|
||||
expect(resolveBrainId(null, '/tmp', badLoader)).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('longestPathPrefixMount', () => {
|
||||
test('returns null when no mount contains cwd', () => {
|
||||
const result = __testing.longestPathPrefixMount([], '/tmp');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -135,10 +135,10 @@ beforeAll(async () => {
|
||||
sharedEngine = new PGLiteEngine();
|
||||
await sharedEngine.connect({});
|
||||
await sharedEngine.initSchema();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
||||
|
||||
afterAll(async () => {
|
||||
await sharedEngine.disconnect();
|
||||
if (sharedEngine) await sharedEngine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
+10
-10
@@ -55,10 +55,10 @@ describe('runDream — brainDir resolution', () => {
|
||||
beforeEach(async () => {
|
||||
repo = makeGitRepo();
|
||||
engine = await makePGLite();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -112,10 +112,10 @@ describe('runDream — --phase <name> restricts the cycle', () => {
|
||||
beforeEach(async () => {
|
||||
repo = makeGitRepo();
|
||||
engine = await makePGLite();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -160,10 +160,10 @@ describe('runDream — output format', () => {
|
||||
beforeEach(async () => {
|
||||
repo = makeGitRepo();
|
||||
engine = await makePGLite();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -198,10 +198,10 @@ describe('runDream — dry-run propagates through to runCycle', () => {
|
||||
beforeEach(async () => {
|
||||
repo = makeGitRepo();
|
||||
engine = await makePGLite();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
@@ -227,10 +227,10 @@ describe('runDream — exit-code semantics', () => {
|
||||
beforeEach(async () => {
|
||||
repo = makeGitRepo();
|
||||
engine = await makePGLite();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + git init; needs breathing room under full-suite load
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -1124,12 +1124,11 @@ describeE2E('E2E: RLS Verification', () => {
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(stderr + stdout).not.toMatch(/42P01|does not exist.*budget/i);
|
||||
|
||||
// Version must have advanced PAST 24. The original test pinned exactly
|
||||
// '24' when LATEST_VERSION was 24 (v0.18.1 era). Since then v25, v26
|
||||
// (v0.19.0), and v27, v28, v29 (v0.21.0 Cathedral II) have shipped.
|
||||
// init runs every pending migration, so after rolling back to 23 the
|
||||
// version advances to LATEST_VERSION. The test's intent is to prove
|
||||
// v24 didn't crash on missing budget_* tables — assert version >= 24.
|
||||
// Version must have advanced PAST 24. Since v0.18.1, v25-v29 (v0.19.0
|
||||
// + v0.21.0 Cathedral II) and v30 (OAuth) have shipped. init runs every
|
||||
// pending migration, so after rolling back to 23 the version advances
|
||||
// to LATEST_VERSION. The test's intent is to prove v24 didn't crash on
|
||||
// missing budget_* tables — assert version >= 24.
|
||||
const afterRows = await conn.unsafe(`SELECT value FROM config WHERE key = 'version'`);
|
||||
const finalVersion = parseInt((afterRows[0] as any).value, 10);
|
||||
expect(finalVersion).toBeGreaterThanOrEqual(24);
|
||||
|
||||
@@ -19,10 +19,10 @@ beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
async function truncateAll() {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
composeResolvers,
|
||||
composeManifests,
|
||||
renderResolverMarkdown,
|
||||
renderManifestJson,
|
||||
writeMountsCache,
|
||||
clearMountsCache,
|
||||
__testing,
|
||||
} from '../src/core/mounts-cache.ts';
|
||||
import { HOST_BRAIN_ID, type MountEntry } from '../src/core/brain-registry.ts';
|
||||
|
||||
const toCleanup: string[] = [];
|
||||
function mktmp(prefix = 'mounts-cache-'): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
toCleanup.push(dir);
|
||||
return dir;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (toCleanup.length > 0) {
|
||||
const p = toCleanup.pop();
|
||||
if (!p) continue;
|
||||
try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
/** Build a tmp skills dir with a minimal RESOLVER.md + manifest.json + SKILL.md stubs. */
|
||||
function makeSkillsDir(skills: Array<{ name: string; trigger: string; section?: string }>): string {
|
||||
const dir = mktmp();
|
||||
mkdirSync(join(dir, 'skills'), { recursive: true });
|
||||
for (const s of skills) {
|
||||
mkdirSync(join(dir, 'skills', s.name), { recursive: true });
|
||||
writeFileSync(join(dir, 'skills', s.name, 'SKILL.md'), `---\nname: ${s.name}\n---\n# ${s.name}\n`);
|
||||
}
|
||||
const resolverLines = ['# RESOLVER', ''];
|
||||
const bySection = new Map<string, Array<{ name: string; trigger: string }>>();
|
||||
for (const s of skills) {
|
||||
const sec = s.section || 'Brain operations';
|
||||
const bucket = bySection.get(sec) ?? [];
|
||||
bucket.push(s);
|
||||
bySection.set(sec, bucket);
|
||||
}
|
||||
for (const [sec, bucket] of bySection.entries()) {
|
||||
resolverLines.push(`## ${sec}`, '', '| Trigger | Skill |', '|---------|-------|');
|
||||
for (const s of bucket) {
|
||||
resolverLines.push(`| ${s.trigger} | \`skills/${s.name}/SKILL.md\` |`);
|
||||
}
|
||||
resolverLines.push('');
|
||||
}
|
||||
writeFileSync(join(dir, 'skills', 'RESOLVER.md'), resolverLines.join('\n'));
|
||||
writeFileSync(
|
||||
join(dir, 'skills', 'manifest.json'),
|
||||
JSON.stringify({ skills: skills.map(s => ({ name: s.name, path: `${s.name}/SKILL.md` })) }, null, 2),
|
||||
);
|
||||
return join(dir, 'skills');
|
||||
}
|
||||
|
||||
function makeMount(id: string, skillsDir: string, enabled = true): MountEntry {
|
||||
// skillsDir is the SKILLS dir; mount.path is the parent (repo root).
|
||||
const repoRoot = join(skillsDir, '..');
|
||||
return {
|
||||
id,
|
||||
path: repoRoot,
|
||||
engine: 'pglite',
|
||||
database_path: join(repoRoot, '.pg'),
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
describe('composeResolvers — host only', () => {
|
||||
test('empty world: empty output', () => {
|
||||
const hostDir = mktmp();
|
||||
const result = composeResolvers(hostDir, []);
|
||||
expect(result.entries).toEqual([]);
|
||||
expect(result.shadows).toEqual([]);
|
||||
expect(result.ambiguities).toEqual([]);
|
||||
});
|
||||
|
||||
test('host skills only: no namespace prefix, brainId=host', () => {
|
||||
const hostSkills = makeSkillsDir([
|
||||
{ name: 'query', trigger: 'search' },
|
||||
{ name: 'enrich', trigger: 'enrich a page' },
|
||||
]);
|
||||
const result = composeResolvers(hostSkills, []);
|
||||
expect(result.entries).toHaveLength(2);
|
||||
expect(result.entries.every(e => e.brainId === HOST_BRAIN_ID)).toBe(true);
|
||||
expect(result.entries.map(e => e.qualifiedName).sort()).toEqual(['enrich', 'query']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeResolvers — mount skills', () => {
|
||||
test('single mount: namespace prefix applied', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'yc-media ingest' }]);
|
||||
const mount = makeMount('yc-media', mountSkills);
|
||||
const result = composeResolvers(hostSkills, [mount]);
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].qualifiedName).toBe('yc-media::ingest');
|
||||
expect(result.entries[0].brainId).toBe('yc-media');
|
||||
expect(result.entries[0].absolutePath).toContain('/skills/ingest/SKILL.md');
|
||||
});
|
||||
|
||||
test('disabled mount is excluded', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
||||
const mount = makeMount('disabled', mountSkills, false);
|
||||
const result = composeResolvers(hostSkills, [mount]);
|
||||
expect(result.entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('two mounts same skill → ambiguity (host does not define it)', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
const m1Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm1 ingest' }]);
|
||||
const m2Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm2 ingest' }]);
|
||||
const m1 = makeMount('alpha', m1Skills);
|
||||
const m2 = makeMount('beta', m2Skills);
|
||||
const result = composeResolvers(hostSkills, [m1, m2]);
|
||||
expect(result.ambiguities).toHaveLength(1);
|
||||
expect(result.ambiguities[0].skillName).toBe('ingest');
|
||||
expect(result.ambiguities[0].mountIds).toEqual(['alpha', 'beta']);
|
||||
// Both entries still surface (via namespace form).
|
||||
const names = result.entries.map(e => e.qualifiedName).sort();
|
||||
expect(names).toEqual(['alpha::ingest', 'beta::ingest']);
|
||||
});
|
||||
|
||||
test('host + mount same name → host wins BARE name, mount reachable via namespace', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host ingest' }]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'mount ingest' }]);
|
||||
const mount = makeMount('yc-media', mountSkills);
|
||||
const result = composeResolvers(hostSkills, [mount]);
|
||||
// Both entries survive: host wins bare 'ingest', but 'yc-media::ingest'
|
||||
// must remain routable (the whole point of namespace-qualified form).
|
||||
expect(result.entries).toHaveLength(2);
|
||||
const host = result.entries.find(e => e.brainId === HOST_BRAIN_ID);
|
||||
const mnt = result.entries.find(e => e.brainId === 'yc-media');
|
||||
expect(host?.qualifiedName).toBe('ingest');
|
||||
expect(mnt?.qualifiedName).toBe('yc-media::ingest');
|
||||
// Shadow recorded so doctor can warn about local-customizing a remote skill
|
||||
expect(result.shadows).toHaveLength(1);
|
||||
expect(result.shadows[0].skillName).toBe('ingest');
|
||||
expect(result.shadows[0].shadowedMounts).toHaveLength(1);
|
||||
expect(result.shadows[0].shadowedMounts[0].mountId).toBe('yc-media');
|
||||
// Not flagged as ambiguity — host wins the bare name cleanly
|
||||
expect(result.ambiguities).toEqual([]);
|
||||
});
|
||||
|
||||
test('host shadows two mounts → all three entries survive, shadow tracks both mounts', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host' }]);
|
||||
const m1Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm1' }]);
|
||||
const m2Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm2' }]);
|
||||
const result = composeResolvers(hostSkills, [makeMount('a', m1Skills), makeMount('b', m2Skills)]);
|
||||
// Host entry + both namespaced mount entries
|
||||
expect(result.entries).toHaveLength(3);
|
||||
expect(result.entries.map(e => e.qualifiedName).sort()).toEqual(['a::ingest', 'b::ingest', 'ingest']);
|
||||
// No ambiguity: host wins bare name
|
||||
expect(result.ambiguities).toEqual([]);
|
||||
expect(result.shadows).toHaveLength(1);
|
||||
expect(result.shadows[0].shadowedMounts.map(m => m.mountId).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('mount without RESOLVER.md contributes no entries (not an error)', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
// Create a mount path with no skills/RESOLVER.md inside
|
||||
const emptyRoot = mktmp();
|
||||
const mount: MountEntry = {
|
||||
id: 'empty', path: emptyRoot, engine: 'pglite', database_path: `${emptyRoot}/.pg`, enabled: true,
|
||||
};
|
||||
const result = composeResolvers(hostSkills, [mount]);
|
||||
expect(result.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeManifests', () => {
|
||||
test('host only: entries preserved without namespace', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
||||
const result = composeManifests(hostSkills, []);
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].name).toBe('query');
|
||||
expect(result.entries[0].brainId).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
|
||||
test('mount entries get namespace prefix (Codex finding #8)', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
||||
const result = composeManifests(hostSkills, [makeMount('yc-media', mountSkills)]);
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0].name).toBe('yc-media::ingest');
|
||||
expect(result.entries[0].brainId).toBe('yc-media');
|
||||
});
|
||||
|
||||
test('manifest keeps namespace-qualified mount entry even when host shadows', () => {
|
||||
// Bare-name resolution is composeResolvers' job. The manifest lists every
|
||||
// addressable skill by its canonical name, so the namespace-qualified
|
||||
// form must survive regardless of host shadow. This matches the
|
||||
// corresponding composeResolvers shadow test.
|
||||
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host' }]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'mount' }]);
|
||||
const result = composeManifests(hostSkills, [makeMount('yc-media', mountSkills)]);
|
||||
expect(result.entries).toHaveLength(2);
|
||||
expect(result.entries.map(e => e.name).sort()).toEqual(['ingest', 'yc-media::ingest']);
|
||||
const host = result.entries.find(e => e.name === 'ingest');
|
||||
const mnt = result.entries.find(e => e.name === 'yc-media::ingest');
|
||||
expect(host?.brainId).toBe(HOST_BRAIN_ID);
|
||||
expect(mnt?.brainId).toBe('yc-media');
|
||||
});
|
||||
|
||||
test('disabled mount excluded', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
||||
const result = composeManifests(hostSkills, [makeMount('off', mountSkills, false)]);
|
||||
expect(result.entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('missing manifest.json in mount → mount contributes nothing (no crash)', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
const emptyRoot = mktmp();
|
||||
mkdirSync(join(emptyRoot, 'skills'), { recursive: true });
|
||||
// No manifest.json written
|
||||
const mount: MountEntry = {
|
||||
id: 'bare', path: emptyRoot, engine: 'pglite', database_path: `${emptyRoot}/.pg`, enabled: true,
|
||||
};
|
||||
expect(() => composeManifests(hostSkills, [mount])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderResolverMarkdown', () => {
|
||||
test('produces stable markdown with brain column', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'search' }]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'media ingest' }]);
|
||||
const composed = composeResolvers(hostSkills, [makeMount('yc-media', mountSkills)]);
|
||||
const md = renderResolverMarkdown(composed);
|
||||
expect(md).toContain('# GBrain Skill Resolver (aggregated)');
|
||||
expect(md).toContain('Auto-generated');
|
||||
expect(md).toContain('| Trigger | Skill | Brain |');
|
||||
expect(md).toContain('| search');
|
||||
expect(md).toContain('| host |');
|
||||
expect(md).toContain('| media ingest');
|
||||
expect(md).toContain('| yc-media |');
|
||||
});
|
||||
|
||||
test('shadows section appears only when shadows exist', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
||||
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
|
||||
const composed = composeResolvers(hostSkills, [makeMount('m', mountSkills)]);
|
||||
const md = renderResolverMarkdown(composed);
|
||||
expect(md).toContain('## Shadows');
|
||||
expect(md).toContain('`ingest` (host) shadows');
|
||||
});
|
||||
|
||||
test('ambiguities section appears only when ambiguities exist', () => {
|
||||
const hostSkills = makeSkillsDir([]);
|
||||
const m1 = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
||||
const m2 = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
|
||||
const composed = composeResolvers(hostSkills, [makeMount('a', m1), makeMount('b', m2)]);
|
||||
const md = renderResolverMarkdown(composed);
|
||||
expect(md).toContain('## Ambiguous bare names');
|
||||
expect(md).toContain('`ingest`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderManifestJson', () => {
|
||||
test('produces parseable JSON with expected shape', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
||||
const composed = composeManifests(hostSkills, []);
|
||||
const json = renderManifestJson(composed);
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.generated_by).toBe('gbrain mounts');
|
||||
expect(parsed.skills).toHaveLength(1);
|
||||
expect(parsed.skills[0].name).toBe('query');
|
||||
expect(parsed.skills[0].brain).toBe(HOST_BRAIN_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeMountsCache + clearMountsCache', () => {
|
||||
test('writes both RESOLVER.md and manifest.json atomically', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
||||
const cacheDir = mktmp();
|
||||
const { resolverPath, manifestPath } = writeMountsCache(hostSkills, [], { cacheDir });
|
||||
expect(existsSync(resolverPath)).toBe(true);
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const md = readFileSync(resolverPath, 'utf-8');
|
||||
expect(md).toContain('# GBrain Skill Resolver (aggregated)');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
expect(manifest.skills).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rewriting overwrites cleanly', () => {
|
||||
const hostSkills1 = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
||||
const cacheDir = mktmp();
|
||||
writeMountsCache(hostSkills1, [], { cacheDir });
|
||||
const hostSkills2 = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
|
||||
writeMountsCache(hostSkills2, [], { cacheDir });
|
||||
const manifest = JSON.parse(readFileSync(join(cacheDir, 'manifest.json'), 'utf-8'));
|
||||
expect(manifest.skills.map((s: { name: string }) => s.name)).toEqual(['ingest']);
|
||||
});
|
||||
|
||||
test('clearMountsCache removes the directory', () => {
|
||||
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
||||
const cacheDir = mktmp();
|
||||
writeMountsCache(hostSkills, [], { cacheDir });
|
||||
expect(existsSync(cacheDir)).toBe(true);
|
||||
clearMountsCache({ cacheDir });
|
||||
expect(existsSync(cacheDir)).toBe(false);
|
||||
});
|
||||
|
||||
test('clearMountsCache on missing dir is a no-op', () => {
|
||||
const fake = join(tmpdir(), `does-not-exist-${Date.now()}`);
|
||||
expect(() => clearMountsCache({ cacheDir: fake })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('skillNameFromRelPath', () => {
|
||||
test('extracts name from skills/ prefix', () => {
|
||||
expect(__testing.skillNameFromRelPath('skills/query/SKILL.md')).toBe('query');
|
||||
expect(__testing.skillNameFromRelPath('skills/yc-media/SKILL.md')).toBe('yc-media');
|
||||
});
|
||||
test('handles name without skills/ prefix', () => {
|
||||
expect(__testing.skillNameFromRelPath('query/SKILL.md')).toBe('query');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, test, expect, afterEach, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir, homedir } from 'os';
|
||||
import { __testing } from '../src/commands/mounts.ts';
|
||||
|
||||
const { parseAddArgs, redactUrl, readMountsFile, writeMountsFile } = __testing;
|
||||
|
||||
const toCleanup: string[] = [];
|
||||
let tempHome: string | null = null;
|
||||
|
||||
function mktmp(prefix = 'mounts-cli-'): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
toCleanup.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect HOME for the duration of a test so writeMountsFile doesn't
|
||||
* touch the user's real ~/.gbrain/mounts.json.
|
||||
*/
|
||||
function withFakeHome<T>(fn: (mountsPath: string) => T): T {
|
||||
const home = mktmp('fake-home-');
|
||||
const prev = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
||||
const mountsPath = join(home, '.gbrain', 'mounts.json');
|
||||
return fn(mountsPath);
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.HOME = prev;
|
||||
else delete process.env.HOME;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (toCleanup.length > 0) {
|
||||
const p = toCleanup.pop();
|
||||
if (!p) continue;
|
||||
try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
describe('parseAddArgs', () => {
|
||||
test('minimal pglite add', () => {
|
||||
const parsed = parseAddArgs([
|
||||
'yc-media',
|
||||
'--path', '/tmp/yc-media',
|
||||
'--engine', 'pglite',
|
||||
'--db-path', '/tmp/yc-media/.pg',
|
||||
]);
|
||||
expect(parsed.id).toBe('yc-media');
|
||||
expect(parsed.engine).toBe('pglite');
|
||||
expect(parsed.database_path).toBe('/tmp/yc-media/.pg');
|
||||
expect(parsed.path.startsWith('/')).toBe(true);
|
||||
});
|
||||
|
||||
test('minimal postgres add', () => {
|
||||
const parsed = parseAddArgs([
|
||||
'yc-politics',
|
||||
'--path', '/tmp/luther',
|
||||
'--engine', 'postgres',
|
||||
'--db-url', 'postgresql://localhost/l',
|
||||
]);
|
||||
expect(parsed.engine).toBe('postgres');
|
||||
expect(parsed.database_url).toBe('postgresql://localhost/l');
|
||||
});
|
||||
|
||||
test('infers engine from --db-url (postgres)', () => {
|
||||
const parsed = parseAddArgs([
|
||||
'a', '--path', '/tmp/a', '--db-url', 'postgresql://x/y',
|
||||
]);
|
||||
expect(parsed.engine).toBe('postgres');
|
||||
});
|
||||
|
||||
test('infers engine from --db-path (pglite)', () => {
|
||||
const parsed = parseAddArgs([
|
||||
'b', '--path', '/tmp/b', '--db-path', '/tmp/b/.pg',
|
||||
]);
|
||||
expect(parsed.engine).toBe('pglite');
|
||||
});
|
||||
|
||||
test('accepts --alias', () => {
|
||||
const parsed = parseAddArgs([
|
||||
'yc-media', '--path', '/tmp/x', '--db-path', '/tmp/x/.pg', '--alias', 'ycm',
|
||||
]);
|
||||
expect(parsed.alias).toBe('ycm');
|
||||
});
|
||||
|
||||
test('rejects missing id', () => {
|
||||
expect(() => parseAddArgs([])).toThrow(/Missing mount id/);
|
||||
});
|
||||
|
||||
test('rejects missing path', () => {
|
||||
expect(() => parseAddArgs(['m', '--db-path', '/tmp/x/.pg'])).toThrow(/Missing --path/);
|
||||
});
|
||||
|
||||
test('rejects invalid engine', () => {
|
||||
expect(() => parseAddArgs([
|
||||
'x', '--path', '/tmp/x', '--engine', 'sqlite',
|
||||
])).toThrow(/Invalid engine/);
|
||||
});
|
||||
|
||||
test('rejects unknown flag', () => {
|
||||
expect(() => parseAddArgs([
|
||||
'x', '--path', '/tmp/x', '--db-path', '/tmp/x/.pg', '--nonsense',
|
||||
])).toThrow(/Unknown flag/);
|
||||
});
|
||||
|
||||
test('rejects no engine + no db flags (cannot infer)', () => {
|
||||
expect(() => parseAddArgs(['x', '--path', '/tmp/x'])).toThrow(/Missing --engine/);
|
||||
});
|
||||
|
||||
test('rejects postgres without --db-url', () => {
|
||||
expect(() => parseAddArgs([
|
||||
'x', '--path', '/tmp/x', '--engine', 'postgres',
|
||||
])).toThrow(/postgres mount requires --db-url/);
|
||||
});
|
||||
|
||||
test('rejects flag-value missing', () => {
|
||||
expect(() => parseAddArgs([
|
||||
'x', '--path',
|
||||
])).toThrow(/Missing value for --path/);
|
||||
});
|
||||
|
||||
test('rejects invalid alias', () => {
|
||||
expect(() => parseAddArgs([
|
||||
'x', '--path', '/tmp/x', '--db-path', '/tmp/x/.pg', '--alias', 'UPPER',
|
||||
])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactUrl', () => {
|
||||
test('strips password from postgres://', () => {
|
||||
const red = redactUrl('postgresql://user:supersecret@db.example.com/brain');
|
||||
expect(red).not.toContain('supersecret');
|
||||
expect(red).toContain('***');
|
||||
expect(red).toContain('db.example.com');
|
||||
});
|
||||
|
||||
test('password-less URLs do not grow ***', () => {
|
||||
const url = 'postgresql://user@db.example.com/brain';
|
||||
const red = redactUrl(url);
|
||||
expect(red).not.toContain('***');
|
||||
expect(red).toContain('user@db.example.com');
|
||||
expect(red).toContain('/brain');
|
||||
});
|
||||
|
||||
test('leaves opaque file:// urls alone', () => {
|
||||
const url = 'file:///home/user/brain/.pglite';
|
||||
expect(redactUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
test('leaves non-URL strings alone', () => {
|
||||
const opaque = '/not/a/url';
|
||||
expect(redactUrl(opaque)).toBe(opaque);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readMountsFile / writeMountsFile', () => {
|
||||
test('empty file returns empty mounts list', () => {
|
||||
const dir = mktmp();
|
||||
const path = join(dir, 'mounts.json');
|
||||
const file = readMountsFile(path);
|
||||
expect(file.version).toBe(1);
|
||||
expect(file.mounts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('round-trip: write then read', () => {
|
||||
const dir = mktmp();
|
||||
const path = join(dir, 'mounts.json');
|
||||
writeMountsFile({
|
||||
version: 1,
|
||||
mounts: [{
|
||||
id: 'yc-media', path: '/tmp/yc', engine: 'pglite', database_path: '/tmp/yc/.pg', enabled: true,
|
||||
}],
|
||||
}, path);
|
||||
const file = readMountsFile(path);
|
||||
expect(file.mounts).toHaveLength(1);
|
||||
expect(file.mounts[0].id).toBe('yc-media');
|
||||
});
|
||||
|
||||
test('write is atomic: no partial file visible mid-write', () => {
|
||||
const dir = mktmp();
|
||||
const path = join(dir, 'mounts.json');
|
||||
writeMountsFile({
|
||||
version: 1,
|
||||
mounts: [{
|
||||
id: 'a', path: '/tmp/a', engine: 'pglite', database_path: '/tmp/a/.pg', enabled: true,
|
||||
}],
|
||||
}, path);
|
||||
expect(existsSync(path)).toBe(true);
|
||||
// .tmp should be gone after atomic rename.
|
||||
expect(existsSync(path + '.tmp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMounts — end-to-end add/list/remove', () => {
|
||||
// These rely on HOME redirection to isolate from the real ~/.gbrain/.
|
||||
// We don't import runMounts directly to avoid stdout spam in test output;
|
||||
// parseAddArgs + readMountsFile + writeMountsFile is enough seam coverage.
|
||||
|
||||
test('add → list → remove roundtrip via seams', () => {
|
||||
withFakeHome((mountsPath) => {
|
||||
// Manually simulate the subcommand sequence using the same seams
|
||||
// runMounts uses internally.
|
||||
let file = readMountsFile(mountsPath);
|
||||
expect(file.mounts).toHaveLength(0);
|
||||
|
||||
// Simulate add
|
||||
const parsed = parseAddArgs([
|
||||
'yc-media',
|
||||
'--path', mountsPath, // use the temp path so existsSync(path) passes
|
||||
'--engine', 'pglite',
|
||||
'--db-path', '/tmp/yc-media/.pg',
|
||||
]);
|
||||
file.mounts.push({
|
||||
id: parsed.id,
|
||||
path: parsed.path,
|
||||
engine: parsed.engine,
|
||||
database_path: parsed.database_path,
|
||||
enabled: true,
|
||||
});
|
||||
writeMountsFile(file, mountsPath);
|
||||
|
||||
// List
|
||||
file = readMountsFile(mountsPath);
|
||||
expect(file.mounts).toHaveLength(1);
|
||||
expect(file.mounts[0].id).toBe('yc-media');
|
||||
|
||||
// Remove
|
||||
file.mounts = file.mounts.filter(m => m.id !== 'yc-media');
|
||||
writeMountsFile(file, mountsPath);
|
||||
|
||||
// Confirm empty
|
||||
file = readMountsFile(mountsPath);
|
||||
expect(file.mounts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,10 +27,10 @@ beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ type: 'pglite' } as never);
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
describe('v0.18.0 — sources table seeded with default row on fresh PGLite', () => {
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
import { vector } from '@electric-sql/pglite/vector';
|
||||
import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm';
|
||||
import { GBrainOAuthProvider } from '../src/core/oauth-provider.ts';
|
||||
import { hashToken, generateToken } from '../src/core/utils.ts';
|
||||
import { PGLITE_SCHEMA_SQL } from '../src/core/pglite-schema.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup: in-memory PGLite with OAuth tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let db: PGlite;
|
||||
let sql: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<any>;
|
||||
let provider: GBrainOAuthProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new PGlite({ extensions: { vector, pg_trgm } });
|
||||
await db.exec(PGLITE_SCHEMA_SQL);
|
||||
|
||||
// Create a tagged template wrapper for PGLite
|
||||
sql = async (strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
const query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? `$${i + 1}` : ''), '');
|
||||
const result = await db.query(query, values as any[]);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
provider = new GBrainOAuthProvider({ sql, tokenTtl: 60, refreshTtl: 300 });
|
||||
}, 30_000); // PGLITE_SCHEMA_SQL execution under full-suite load can exceed default 5s
|
||||
|
||||
afterAll(async () => {
|
||||
if (db) await db.close();
|
||||
}, 15_000);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hashToken + generateToken utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hashToken', () => {
|
||||
test('produces consistent SHA-256 hex', () => {
|
||||
const hash = hashToken('test-token');
|
||||
expect(hash).toHaveLength(64);
|
||||
expect(hashToken('test-token')).toBe(hash); // deterministic
|
||||
});
|
||||
|
||||
test('different inputs produce different hashes', () => {
|
||||
expect(hashToken('a')).not.toBe(hashToken('b'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
test('produces prefixed random hex', () => {
|
||||
const token = generateToken('gbrain_cl_');
|
||||
expect(token).toStartWith('gbrain_cl_');
|
||||
expect(token).toHaveLength('gbrain_cl_'.length + 64); // 32 bytes = 64 hex chars
|
||||
});
|
||||
|
||||
test('tokens are unique', () => {
|
||||
const a = generateToken('test_');
|
||||
const b = generateToken('test_');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('client registration', () => {
|
||||
test('registerClientManual creates a client', async () => {
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
'test-agent', ['client_credentials'], 'read write',
|
||||
);
|
||||
expect(clientId).toStartWith('gbrain_cl_');
|
||||
expect(clientSecret).toStartWith('gbrain_cs_');
|
||||
|
||||
// Verify client exists in DB
|
||||
const client = await provider.clientsStore.getClient(clientId);
|
||||
expect(client).toBeDefined();
|
||||
expect(client!.client_name).toBe('test-agent');
|
||||
});
|
||||
|
||||
test('getClient returns undefined for unknown client', async () => {
|
||||
const client = await provider.clientsStore.getClient('nonexistent');
|
||||
expect(client).toBeUndefined();
|
||||
});
|
||||
|
||||
test('duplicate client_id is rejected', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'dup-test', ['client_credentials'], 'read',
|
||||
);
|
||||
// Try to insert same client_id directly
|
||||
await expect(
|
||||
sql`INSERT INTO oauth_clients (client_id, client_name, scope) VALUES (${clientId}, ${'dup'}, ${'read'})`,
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client Credentials Exchange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('client credentials', () => {
|
||||
let clientId: string;
|
||||
let clientSecret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await provider.registerClientManual(
|
||||
'cc-test-agent', ['client_credentials'], 'read write',
|
||||
);
|
||||
clientId = result.clientId;
|
||||
clientSecret = result.clientSecret;
|
||||
});
|
||||
|
||||
test('valid exchange returns access token', async () => {
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
||||
expect(tokens.access_token).toStartWith('gbrain_at_');
|
||||
expect(tokens.token_type).toBe('bearer');
|
||||
expect(tokens.expires_in).toBe(60);
|
||||
expect(tokens.scope).toBe('read');
|
||||
});
|
||||
|
||||
test('no refresh token issued for CC grant', async () => {
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
||||
expect(tokens.refresh_token).toBeUndefined();
|
||||
});
|
||||
|
||||
test('wrong secret is rejected', async () => {
|
||||
await expect(
|
||||
provider.exchangeClientCredentials(clientId, 'wrong-secret', 'read'),
|
||||
).rejects.toThrow('Invalid client secret');
|
||||
});
|
||||
|
||||
test('client without CC grant is rejected', async () => {
|
||||
const { clientId: noCC } = await provider.registerClientManual(
|
||||
'no-cc-agent', ['authorization_code'], 'read',
|
||||
);
|
||||
await expect(
|
||||
provider.exchangeClientCredentials(noCC, 'any-secret', 'read'),
|
||||
).rejects.toThrow('not authorized');
|
||||
});
|
||||
|
||||
test('scope is filtered to allowed scopes', async () => {
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read write admin');
|
||||
// Client only has 'read write', admin should be filtered out
|
||||
expect(tokens.scope).not.toContain('admin');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('verifyAccessToken', () => {
|
||||
test('valid token returns auth info', async () => {
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
'verify-test', ['client_credentials'], 'read write',
|
||||
);
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
||||
const authInfo = await provider.verifyAccessToken(tokens.access_token);
|
||||
|
||||
expect(authInfo.clientId).toBe(clientId);
|
||||
expect(authInfo.scopes).toContain('read');
|
||||
expect(authInfo.token).toBe(tokens.access_token);
|
||||
});
|
||||
|
||||
test('expired token is rejected', async () => {
|
||||
// Insert a token that's already expired
|
||||
const expiredToken = generateToken('gbrain_at_');
|
||||
const hash = hashToken(expiredToken);
|
||||
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
|
||||
await sql`
|
||||
INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
|
||||
VALUES (${hash}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${Math.floor(Date.now() / 1000) - 100})
|
||||
`;
|
||||
await expect(provider.verifyAccessToken(expiredToken)).rejects.toThrow('expired');
|
||||
});
|
||||
|
||||
test('unknown token is rejected', async () => {
|
||||
await expect(provider.verifyAccessToken('nonexistent-token')).rejects.toThrow('Invalid token');
|
||||
});
|
||||
|
||||
test('legacy access_tokens fallback works', async () => {
|
||||
// Insert a legacy bearer token
|
||||
const legacyToken = generateToken('gbrain_');
|
||||
const hash = hashToken(legacyToken);
|
||||
await sql`
|
||||
INSERT INTO access_tokens (id, name, token_hash)
|
||||
VALUES (${crypto.randomUUID()}, ${'legacy-agent'}, ${hash})
|
||||
`;
|
||||
|
||||
const authInfo = await provider.verifyAccessToken(legacyToken);
|
||||
expect(authInfo.clientId).toBe('legacy-agent');
|
||||
expect(authInfo.scopes).toEqual(['read', 'write', 'admin']); // grandfathered full access
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Revocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('revokeToken', () => {
|
||||
test('revoked token no longer verifies', async () => {
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
'revoke-test', ['client_credentials'], 'read',
|
||||
);
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, clientSecret, 'read');
|
||||
|
||||
// Verify token works
|
||||
const authInfo = await provider.verifyAccessToken(tokens.access_token);
|
||||
expect(authInfo.clientId).toBe(clientId);
|
||||
|
||||
// Revoke it
|
||||
const client = (await provider.clientsStore.getClient(clientId))!;
|
||||
await provider.revokeToken!(client, { token: tokens.access_token });
|
||||
|
||||
// Should no longer verify
|
||||
await expect(provider.verifyAccessToken(tokens.access_token)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('revoking already-revoked token is a no-op', async () => {
|
||||
// This should not throw
|
||||
const client = (await provider.clientsStore.getClient(
|
||||
(await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0].client_id as string,
|
||||
))!;
|
||||
await provider.revokeToken!(client, { token: 'already-gone' });
|
||||
// No error = pass
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization Code Flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('authorization code flow', () => {
|
||||
test('code issuance and exchange', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'authcode-test', ['authorization_code'], 'read write',
|
||||
['http://localhost:3000/callback'],
|
||||
);
|
||||
const client = (await provider.clientsStore.getClient(clientId))!;
|
||||
|
||||
// Mock Express response for authorize
|
||||
let redirectUrl = '';
|
||||
const mockRes = {
|
||||
redirect: (url: string) => { redirectUrl = url; },
|
||||
} as any;
|
||||
|
||||
await provider.authorize(client, {
|
||||
codeChallenge: 'test-challenge-hash',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
scopes: ['read', 'write'],
|
||||
state: 'test-state',
|
||||
}, mockRes);
|
||||
|
||||
expect(redirectUrl).toContain('code=gbrain_code_');
|
||||
expect(redirectUrl).toContain('state=test-state');
|
||||
|
||||
// Extract code from redirect URL
|
||||
const url = new URL(redirectUrl);
|
||||
const code = url.searchParams.get('code')!;
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await provider.exchangeAuthorizationCode(client, code);
|
||||
expect(tokens.access_token).toStartWith('gbrain_at_');
|
||||
expect(tokens.refresh_token).toBeDefined(); // Auth code flow includes refresh
|
||||
});
|
||||
|
||||
test('code is single-use', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'single-use-test', ['authorization_code'], 'read',
|
||||
['http://localhost:3000/callback'],
|
||||
);
|
||||
const client = (await provider.clientsStore.getClient(clientId))!;
|
||||
|
||||
let redirectUrl = '';
|
||||
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
||||
|
||||
await provider.authorize(client, {
|
||||
codeChallenge: 'challenge',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
scopes: ['read'],
|
||||
}, mockRes);
|
||||
|
||||
const code = new URL(redirectUrl).searchParams.get('code')!;
|
||||
|
||||
// First exchange works
|
||||
await provider.exchangeAuthorizationCode(client, code);
|
||||
|
||||
// Second exchange fails (code consumed)
|
||||
await expect(provider.exchangeAuthorizationCode(client, code)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('expired code is rejected', async () => {
|
||||
// Insert an already-expired code
|
||||
const expiredCode = generateToken('gbrain_code_');
|
||||
const hash = hashToken(expiredCode);
|
||||
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
|
||||
|
||||
await sql`
|
||||
INSERT INTO oauth_codes (code_hash, client_id, scopes, code_challenge,
|
||||
redirect_uri, expires_at)
|
||||
VALUES (${hash}, ${firstClient.client_id as string}, ${'{read}'},
|
||||
${'challenge'}, ${'http://localhost/cb'}, ${Math.floor(Date.now() / 1000) - 100})
|
||||
`;
|
||||
|
||||
const client = (await provider.clientsStore.getClient(firstClient.client_id as string))!;
|
||||
await expect(provider.exchangeAuthorizationCode(client, expiredCode)).rejects.toThrow();
|
||||
});
|
||||
|
||||
// CSO finding #2 regression. The pre-fix SELECT-then-DELETE pattern let two
|
||||
// concurrent token requests with the same code both pass the SELECT, both
|
||||
// running DELETE (no-op on second) and both calling issueTokens. The fix is
|
||||
// DELETE...RETURNING in one statement; this test fires N=10 concurrent
|
||||
// exchanges and asserts exactly one succeeds.
|
||||
test('concurrent exchange requests: only one succeeds (TOCTOU race)', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'toctou-code-test', ['authorization_code'], 'read',
|
||||
['http://localhost:3000/callback'],
|
||||
);
|
||||
const client = (await provider.clientsStore.getClient(clientId))!;
|
||||
|
||||
let redirectUrl = '';
|
||||
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
||||
await provider.authorize(client, {
|
||||
codeChallenge: 'challenge',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
scopes: ['read'],
|
||||
}, mockRes);
|
||||
const code = new URL(redirectUrl).searchParams.get('code')!;
|
||||
|
||||
const N = 10;
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: N }, () => provider.exchangeAuthorizationCode(client, code)),
|
||||
);
|
||||
const successes = results.filter(r => r.status === 'fulfilled');
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
expect(successes.length).toBe(1);
|
||||
expect(failures.length).toBe(N - 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh Token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('refresh token', () => {
|
||||
test('valid refresh rotates tokens', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'refresh-test', ['authorization_code'], 'read write',
|
||||
['http://localhost:3000/callback'],
|
||||
);
|
||||
const client = (await provider.clientsStore.getClient(clientId))!;
|
||||
|
||||
let redirectUrl = '';
|
||||
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
||||
|
||||
await provider.authorize(client, {
|
||||
codeChallenge: 'challenge',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
scopes: ['read', 'write'],
|
||||
}, mockRes);
|
||||
|
||||
const code = new URL(redirectUrl).searchParams.get('code')!;
|
||||
const tokens = await provider.exchangeAuthorizationCode(client, code);
|
||||
|
||||
// Refresh
|
||||
const newTokens = await provider.exchangeRefreshToken(client, tokens.refresh_token!, ['read']);
|
||||
expect(newTokens.access_token).not.toBe(tokens.access_token);
|
||||
expect(newTokens.refresh_token).toBeDefined();
|
||||
expect(newTokens.refresh_token).not.toBe(tokens.refresh_token); // rotated
|
||||
|
||||
// Old refresh token should no longer work
|
||||
await expect(provider.exchangeRefreshToken(client, tokens.refresh_token!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
// CSO finding #3 regression. Same TOCTOU pattern as auth code; the fix is
|
||||
// DELETE...RETURNING. Detection of stolen refresh tokens (RFC 6749 §10.4)
|
||||
// depends on second-use failure, so two concurrent succeed = no detection.
|
||||
test('concurrent refresh requests: only one succeeds (TOCTOU race)', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'toctou-refresh-test', ['authorization_code'], 'read',
|
||||
['http://localhost:3000/callback'],
|
||||
);
|
||||
const client = (await provider.clientsStore.getClient(clientId))!;
|
||||
|
||||
let redirectUrl = '';
|
||||
const mockRes = { redirect: (url: string) => { redirectUrl = url; } } as any;
|
||||
await provider.authorize(client, {
|
||||
codeChallenge: 'challenge',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
scopes: ['read'],
|
||||
}, mockRes);
|
||||
const code = new URL(redirectUrl).searchParams.get('code')!;
|
||||
const tokens = await provider.exchangeAuthorizationCode(client, code);
|
||||
|
||||
const N = 10;
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: N }, () => provider.exchangeRefreshToken(client, tokens.refresh_token!)),
|
||||
);
|
||||
const successes = results.filter(r => r.status === 'fulfilled');
|
||||
expect(successes.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token Sweep
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sweepExpiredTokens', () => {
|
||||
test('removes expired tokens', async () => {
|
||||
// Insert some expired tokens
|
||||
const firstClient = (await sql`SELECT client_id FROM oauth_clients LIMIT 1`)[0];
|
||||
const expired1 = hashToken(generateToken('sweep_'));
|
||||
const expired2 = hashToken(generateToken('sweep_'));
|
||||
|
||||
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
|
||||
VALUES (${expired1}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${1})`;
|
||||
await sql`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
|
||||
VALUES (${expired2}, ${'access'}, ${firstClient.client_id as string}, ${'{read}'}, ${2})`;
|
||||
|
||||
await provider.sweepExpiredTokens();
|
||||
|
||||
// Verify they're gone
|
||||
const remaining = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE expires_at < 100`;
|
||||
expect(remaining[0].count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope Annotations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('operation scope annotations', () => {
|
||||
test('all operations have a scope', () => {
|
||||
const { operations } = require('../src/core/operations.ts');
|
||||
for (const op of operations) {
|
||||
expect(op.scope, `${op.name} missing scope`).toBeDefined();
|
||||
expect(['read', 'write', 'admin']).toContain(op.scope);
|
||||
}
|
||||
});
|
||||
|
||||
test('mutating operations are write or admin scoped', () => {
|
||||
const { operations } = require('../src/core/operations.ts');
|
||||
for (const op of operations) {
|
||||
if (op.mutating) {
|
||||
expect(['write', 'admin'], `${op.name} is mutating but not write/admin`).toContain(op.scope);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('sync_brain and file_upload are localOnly', () => {
|
||||
const { operationsByName } = require('../src/core/operations.ts');
|
||||
expect(operationsByName.sync_brain.localOnly).toBe(true);
|
||||
expect(operationsByName.file_upload.localOnly).toBe(true);
|
||||
});
|
||||
|
||||
test('file_list and file_url are localOnly', () => {
|
||||
const { operationsByName } = require('../src/core/operations.ts');
|
||||
expect(operationsByName.file_list.localOnly).toBe(true);
|
||||
expect(operationsByName.file_url.localOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSO finding #5 — pgArray escape + DCR redirect_uri validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('redirect_uri validation (DCR)', () => {
|
||||
test('http://localhost is allowed (loopback exception)', async () => {
|
||||
const result = await provider.clientsStore.registerClient!({
|
||||
client_name: 'localhost-ok',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
scope: 'read',
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
});
|
||||
expect(result.client_id).toStartWith('gbrain_cl_');
|
||||
});
|
||||
|
||||
test('https:// is allowed', async () => {
|
||||
const result = await provider.clientsStore.registerClient!({
|
||||
client_name: 'https-ok',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
scope: 'read',
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
});
|
||||
expect(result.client_id).toStartWith('gbrain_cl_');
|
||||
});
|
||||
|
||||
test('plaintext http:// (non-loopback) is rejected', async () => {
|
||||
await expect(
|
||||
provider.clientsStore.registerClient!({
|
||||
client_name: 'http-rejected',
|
||||
redirect_uris: ['http://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
scope: 'read',
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
}),
|
||||
).rejects.toThrow(/https/);
|
||||
});
|
||||
|
||||
test('non-URL string is rejected', async () => {
|
||||
await expect(
|
||||
provider.clientsStore.registerClient!({
|
||||
client_name: 'garbage',
|
||||
redirect_uris: ['not-a-url'],
|
||||
grant_types: ['authorization_code'],
|
||||
scope: 'read',
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// pgArray escape regression: an element containing a comma must be stored
|
||||
// as ONE element, not parsed by Postgres as TWO. Without the fix, the
|
||||
// comma would smuggle a second redirect_uri into the registered list.
|
||||
test('redirect_uri with embedded comma stored as single element', async () => {
|
||||
// Use a localhost URI with comma in the path so it passes HTTPS validation.
|
||||
const trickyUri = 'http://localhost:3000/cb,evil';
|
||||
const result = await provider.clientsStore.registerClient!({
|
||||
client_name: 'comma-test',
|
||||
redirect_uris: [trickyUri],
|
||||
grant_types: ['authorization_code'],
|
||||
scope: 'read',
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
});
|
||||
|
||||
// Read back from the DB and confirm exactly one element.
|
||||
const stored = await provider.clientsStore.getClient(result.client_id);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.redirect_uris).toHaveLength(1);
|
||||
expect(stored!.redirect_uris[0]).toBe(trickyUri);
|
||||
});
|
||||
});
|
||||
@@ -216,10 +216,10 @@ describe('findOrphans (engine-injected)', () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.disconnect();
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
test('returns pages with no inbound links, excluding pseudo-pages', async () => {
|
||||
|
||||
Reference in New Issue
Block a user