mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* 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>
1396 lines
58 KiB
TypeScript
1396 lines
58 KiB
TypeScript
/**
|
|
* E2E Mechanical Tests — Tier 1 (no API keys required)
|
|
*
|
|
* Tests all operations against a real Postgres+pgvector database.
|
|
* Requires DATABASE_URL env var or .env.testing file.
|
|
*
|
|
* Run: DATABASE_URL=... bun test test/e2e/mechanical.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { execSync } from 'child_process';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
hasDatabase, setupDB, teardownDB, getEngine, getConn,
|
|
importFixtures, importFixture, time, dumpDBState, FIXTURES_PATH,
|
|
} from './helpers.ts';
|
|
import { operationsByName, operations } from '../../src/core/operations.ts';
|
|
import type { OperationContext } from '../../src/core/operations.ts';
|
|
import { importFromContent } from '../../src/core/import-file.ts';
|
|
|
|
// Skip all E2E tests if no database is configured
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
|
|
function makeCtx(opts: { remote?: boolean } = {}): OperationContext {
|
|
return {
|
|
engine: getEngine(),
|
|
config: { engine: 'postgres', database_url: process.env.DATABASE_URL! },
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
// Default: trusted local invocation (matches `gbrain call` semantics).
|
|
remote: opts.remote ?? false,
|
|
};
|
|
}
|
|
|
|
async function callOp(name: string, params: Record<string, unknown> = {}) {
|
|
const op = operationsByName[name];
|
|
if (!op) throw new Error(`Unknown operation: ${name}`);
|
|
return op.handler(makeCtx(), params);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Page CRUD
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Page CRUD', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('fixture import creates correct page count', async () => {
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(16);
|
|
});
|
|
|
|
test('get_page returns correct data for person', async () => {
|
|
const page = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(page.title).toBe('Sarah Chen');
|
|
expect(page.type).toBe('person');
|
|
expect(page.compiled_truth).toContain('NovaMind');
|
|
expect(page.tags).toContain('founder');
|
|
expect(page.tags).toContain('yc-w25');
|
|
});
|
|
|
|
test('get_page returns correct data for concept', async () => {
|
|
const page = await callOp('get_page', { slug: 'concepts/retrieval-augmented-generation' }) as any;
|
|
expect(page.title).toBe('Retrieval-Augmented Generation');
|
|
expect(page.type).toBe('concept');
|
|
expect(page.compiled_truth).toContain('検索拡張生成');
|
|
});
|
|
|
|
test('get_page for company includes key details', async () => {
|
|
const page = await callOp('get_page', { slug: 'companies/novamind' }) as any;
|
|
expect(page.type).toBe('company');
|
|
expect(page.compiled_truth).toContain('Sarah Chen');
|
|
});
|
|
|
|
test('list_pages type filter returns correct count', async () => {
|
|
const people = await callOp('list_pages', { type: 'person' }) as any[];
|
|
expect(people.length).toBe(3);
|
|
|
|
const companies = await callOp('list_pages', { type: 'company' }) as any[];
|
|
expect(companies.length).toBe(3); // novamind, threshold-ventures, ohmygreen
|
|
|
|
const concepts = await callOp('list_pages', { type: 'concept' }) as any[];
|
|
expect(concepts.length).toBe(5); // compiled-truth, hybrid-search, RAG, notes-march-2024, big-file
|
|
});
|
|
|
|
test('list_pages tag filter works', async () => {
|
|
const ycPages = await callOp('list_pages', { tag: 'yc-w25' }) as any[];
|
|
expect(ycPages.length).toBeGreaterThanOrEqual(2);
|
|
expect(ycPages.some((p: any) => p.slug === 'people/sarah-chen')).toBe(true);
|
|
});
|
|
|
|
test('put_page updates existing page', async () => {
|
|
const updated = readFileSync(join(FIXTURES_PATH, 'people/sarah-chen.md'), 'utf-8')
|
|
.replace('Stanford CS', 'MIT CS');
|
|
// Use importFromContent directly with noEmbed to avoid OpenAI timeout
|
|
const engine = getEngine();
|
|
const result = await importFromContent(engine, 'people/sarah-chen', updated, { noEmbed: true });
|
|
expect(result.status).toBe('imported');
|
|
const page = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(page.compiled_truth).toContain('MIT CS');
|
|
});
|
|
|
|
test('delete_page removes page and others survive', async () => {
|
|
await callOp('delete_page', { slug: 'sources/crustdata-sarah-chen' });
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(15);
|
|
|
|
// Other pages still exist
|
|
const sarah = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(sarah.title).toBe('Sarah Chen');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Search
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Search', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('keyword search for "NovaMind" returns multiple hits', async () => {
|
|
const results = await callOp('search', { query: 'NovaMind' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(3);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('companies/novamind');
|
|
});
|
|
|
|
test('keyword search for "Threshold Ventures" finds investor', async () => {
|
|
const results = await callOp('search', { query: 'Threshold Ventures' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('companies/threshold-ventures');
|
|
});
|
|
|
|
test('keyword search for "Stanford" finds Priya', async () => {
|
|
const results = await callOp('search', { query: 'Stanford' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('people/priya-patel');
|
|
});
|
|
|
|
test('keyword search for nonexistent term returns empty', async () => {
|
|
const results = await callOp('search', { query: 'xyznonexistent123' }) as any[];
|
|
expect(results.length).toBe(0);
|
|
});
|
|
|
|
test('search quality: precision@5 for known queries', async () => {
|
|
const groundTruth: Record<string, string[]> = {
|
|
'NovaMind': ['people/sarah-chen', 'companies/novamind', 'deals/novamind-seed'],
|
|
'hybrid search': ['concepts/hybrid-search', 'concepts/retrieval-augmented-generation'],
|
|
'compiled truth': ['concepts/compiled-truth'],
|
|
};
|
|
|
|
const scores: Record<string, number> = {};
|
|
for (const [query, expected] of Object.entries(groundTruth)) {
|
|
const results = await callOp('search', { query, limit: 5 }) as any[];
|
|
const topSlugs = results.slice(0, 5).map((r: any) => r.slug);
|
|
const hits = expected.filter(e => topSlugs.includes(e));
|
|
scores[query] = hits.length / Math.min(expected.length, 5);
|
|
}
|
|
|
|
console.log('\n Search Quality (precision@5, keyword-only):');
|
|
for (const [query, score] of Object.entries(scores)) {
|
|
console.log(` "${query}": ${(score * 100).toFixed(0)}%`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Links
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Links', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('add_link + get_links + get_backlinks round trip', async () => {
|
|
await callOp('add_link', {
|
|
from: 'people/sarah-chen',
|
|
to: 'companies/novamind',
|
|
link_type: 'founded',
|
|
context: 'CEO and founder since 2024',
|
|
});
|
|
|
|
const links = await callOp('get_links', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(links.some((l: any) => l.to_slug === 'companies/novamind' || l.to_page_slug === 'companies/novamind')).toBe(true);
|
|
|
|
const backlinks = await callOp('get_backlinks', { slug: 'companies/novamind' }) as any[];
|
|
expect(backlinks.some((l: any) => l.from_slug === 'people/sarah-chen' || l.from_page_slug === 'people/sarah-chen')).toBe(true);
|
|
});
|
|
|
|
test('traverse_graph finds connected pages', async () => {
|
|
// Links should already be added from prior test in this describe block
|
|
const graph = await callOp('traverse_graph', { slug: 'people/sarah-chen', depth: 2 }) as any;
|
|
expect(Array.isArray(graph)).toBe(true);
|
|
expect(graph.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('remove_link removes the link', async () => {
|
|
await callOp('add_link', { from: 'people/marcus-reid', to: 'companies/threshold-ventures' });
|
|
await callOp('remove_link', { from: 'people/marcus-reid', to: 'companies/threshold-ventures' });
|
|
|
|
const links = await callOp('get_links', { slug: 'people/marcus-reid' }) as any[];
|
|
const hasLink = links.some((l: any) =>
|
|
(l.to_slug || l.to_page_slug) === 'companies/threshold-ventures'
|
|
);
|
|
expect(hasLink).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Tags
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Tags', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('get_tags returns imported tags', async () => {
|
|
const tags = await callOp('get_tags', { slug: 'people/sarah-chen' }) as string[];
|
|
expect(tags).toContain('founder');
|
|
expect(tags).toContain('yc-w25');
|
|
expect(tags).toContain('ai-agents');
|
|
});
|
|
|
|
test('add_tag + remove_tag round trip', async () => {
|
|
await callOp('add_tag', { slug: 'people/marcus-reid', tag: 'test-tag' });
|
|
let tags = await callOp('get_tags', { slug: 'people/marcus-reid' }) as string[];
|
|
expect(tags).toContain('test-tag');
|
|
|
|
await callOp('remove_tag', { slug: 'people/marcus-reid', tag: 'test-tag' });
|
|
tags = await callOp('get_tags', { slug: 'people/marcus-reid' }) as string[];
|
|
expect(tags).not.toContain('test-tag');
|
|
});
|
|
|
|
test('list_pages with tag filter finds tagged pages', async () => {
|
|
await callOp('add_tag', { slug: 'people/priya-patel', tag: 'test-search-tag' });
|
|
const pages = await callOp('list_pages', { tag: 'test-search-tag' }) as any[];
|
|
expect(pages.length).toBe(1);
|
|
expect(pages[0].slug).toBe('people/priya-patel');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Timeline
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Timeline', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('add_timeline_entry + get_timeline round trip', async () => {
|
|
await callOp('add_timeline_entry', {
|
|
slug: 'people/sarah-chen',
|
|
date: '2025-04-01',
|
|
summary: 'Test timeline entry',
|
|
detail: 'Added via E2E test',
|
|
source: 'e2e-test',
|
|
});
|
|
|
|
const timeline = await callOp('get_timeline', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(timeline.length).toBeGreaterThanOrEqual(1);
|
|
const entry = timeline.find((e: any) => e.summary === 'Test timeline entry');
|
|
expect(entry).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Batch methods (addLinksBatch / addTimelineEntriesBatch)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// Postgres-engine batch methods use postgres-js's sql(rows, 'col1', ...) helper,
|
|
// which is structurally different from PGLite's manual $N placeholder construction
|
|
// (covered in test/pglite-engine.test.ts). These tests verify the postgres-js code
|
|
// path against a real Postgres against the same invariants.
|
|
|
|
describeE2E('E2E: addLinksBatch (postgres-engine)', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('empty batch returns 0 with no DB call', async () => {
|
|
const engine = getEngine();
|
|
expect(await engine.addLinksBatch([])).toBe(0);
|
|
});
|
|
|
|
test('within-batch duplicates dedup via ON CONFLICT (no 21000 cardinality error)', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
// Deterministic cleanup so re-runs aren't perturbed by prior fixture state.
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-dup'`;
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-dup' },
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-dup' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-dup'`;
|
|
});
|
|
|
|
test('rows with missing slug silently dropped by JOIN', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-missing'`;
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/does-not-exist', to_slug: 'companies/novamind', link_type: 'e2e-batch-missing' },
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-missing' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-missing'`;
|
|
});
|
|
|
|
test('half-existing batch returns count of new only', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-half'`;
|
|
await engine.addLink('people/sarah-chen', 'companies/novamind', 'pre-existing', 'e2e-batch-half');
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind', link_type: 'e2e-batch-half' },
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'people/marcus-reid', link_type: 'e2e-batch-half' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM links WHERE link_type = 'e2e-batch-half'`;
|
|
});
|
|
|
|
test('missing optional fields normalize to empty strings (NOT NULL safety)', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM links WHERE link_type = ''`;
|
|
// No link_type, no context — must default to '' to satisfy NOT NULL.
|
|
const inserted = await engine.addLinksBatch([
|
|
{ from_slug: 'people/sarah-chen', to_slug: 'companies/novamind' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
const rows = await conn`
|
|
SELECT link_type, context FROM links
|
|
WHERE from_page_id = (SELECT id FROM pages WHERE slug = 'people/sarah-chen')
|
|
AND to_page_id = (SELECT id FROM pages WHERE slug = 'companies/novamind')
|
|
AND link_type = ''
|
|
`;
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].context).toBe('');
|
|
await conn`DELETE FROM links WHERE link_type = ''`;
|
|
});
|
|
});
|
|
|
|
describeE2E('E2E: addTimelineEntriesBatch (postgres-engine)', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('empty batch returns 0', async () => {
|
|
const engine = getEngine();
|
|
expect(await engine.addTimelineEntriesBatch([])).toBe(0);
|
|
});
|
|
|
|
test('within-batch duplicates dedup via ON CONFLICT', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-dup'`;
|
|
const inserted = await engine.addTimelineEntriesBatch([
|
|
{ slug: 'people/sarah-chen', date: '2025-05-01', summary: 'e2e-batch-tl-dup' },
|
|
{ slug: 'people/sarah-chen', date: '2025-05-01', summary: 'e2e-batch-tl-dup' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-dup'`;
|
|
});
|
|
|
|
test('rows with missing slug silently dropped by JOIN', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-missing'`;
|
|
const inserted = await engine.addTimelineEntriesBatch([
|
|
{ slug: 'people/no-such-page', date: '2025-05-02', summary: 'e2e-batch-tl-missing' },
|
|
{ slug: 'people/sarah-chen', date: '2025-05-02', summary: 'e2e-batch-tl-missing' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM timeline_entries WHERE summary = 'e2e-batch-tl-missing'`;
|
|
});
|
|
|
|
test('mix of new + existing returns count of new only', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
await conn`DELETE FROM timeline_entries WHERE summary IN ('e2e-batch-tl-half-1', 'e2e-batch-tl-half-2')`;
|
|
await engine.addTimelineEntry('people/sarah-chen', { date: '2025-05-03', summary: 'e2e-batch-tl-half-1' });
|
|
const inserted = await engine.addTimelineEntriesBatch([
|
|
{ slug: 'people/sarah-chen', date: '2025-05-03', summary: 'e2e-batch-tl-half-1' },
|
|
{ slug: 'people/sarah-chen', date: '2025-05-04', summary: 'e2e-batch-tl-half-2' },
|
|
]);
|
|
expect(inserted).toBe(1);
|
|
await conn`DELETE FROM timeline_entries WHERE summary IN ('e2e-batch-tl-half-1', 'e2e-batch-tl-half-2')`;
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Versions
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Versions', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('put_page creates version, revert restores', async () => {
|
|
const original = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
|
|
// Modify page using importFromContent with noEmbed
|
|
const modified = readFileSync(join(FIXTURES_PATH, 'people/sarah-chen.md'), 'utf-8')
|
|
.replace('Sarah Chen', 'Sarah Chen (Modified)');
|
|
const engine = getEngine();
|
|
await importFromContent(engine, 'people/sarah-chen', modified, { noEmbed: true });
|
|
|
|
// Check versions exist
|
|
const versions = await callOp('get_versions', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(versions.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Revert to first version
|
|
const firstVersion = versions[versions.length - 1];
|
|
await callOp('revert_version', { slug: 'people/sarah-chen', version_id: firstVersion.id });
|
|
|
|
const reverted = await callOp('get_page', { slug: 'people/sarah-chen' }) as any;
|
|
expect(reverted.compiled_truth).not.toContain('(Modified)');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Admin
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Admin', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('get_stats returns valid structure', async () => {
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(16);
|
|
expect(typeof stats.chunk_count).toBe('number');
|
|
});
|
|
|
|
test('get_health returns valid structure', async () => {
|
|
const health = await callOp('get_health') as any;
|
|
expect(health).toBeDefined();
|
|
expect(typeof health.page_count).toBe('number');
|
|
expect(typeof health.embed_coverage).toBe('number');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Chunks & Resolution
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Chunks & Resolution', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('get_chunks returns chunks for imported page', async () => {
|
|
const chunks = await callOp('get_chunks', { slug: 'people/sarah-chen' }) as any[];
|
|
expect(chunks.length).toBeGreaterThan(0);
|
|
expect(chunks[0].chunk_text).toBeTruthy();
|
|
});
|
|
|
|
test('resolve_slugs finds partial match', async () => {
|
|
const matches = await callOp('resolve_slugs', { partial: 'sarah' }) as string[];
|
|
expect(matches).toContain('people/sarah-chen');
|
|
});
|
|
|
|
test('resolve_slugs finds exact match', async () => {
|
|
const matches = await callOp('resolve_slugs', { partial: 'people/sarah-chen' }) as string[];
|
|
expect(matches).toContain('people/sarah-chen');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Ingest Log & Raw Data
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Ingest Log & Raw Data', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('log_ingest + get_ingest_log round trip', async () => {
|
|
await callOp('log_ingest', {
|
|
source_type: 'e2e-test',
|
|
source_ref: 'test-run-1',
|
|
pages_updated: ['people/sarah-chen', 'companies/novamind'],
|
|
summary: 'E2E test ingest',
|
|
});
|
|
|
|
const log = await callOp('get_ingest_log', { limit: 5 }) as any[];
|
|
expect(log.length).toBeGreaterThanOrEqual(1);
|
|
const entry = log.find((e: any) => e.source_ref === 'test-run-1');
|
|
expect(entry).toBeDefined();
|
|
expect(entry.source_type).toBe('e2e-test');
|
|
});
|
|
|
|
test('put_raw_data + get_raw_data round trip', async () => {
|
|
const testData = { education: 'Stanford CS 2020', title: 'CEO' };
|
|
await callOp('put_raw_data', {
|
|
slug: 'people/sarah-chen',
|
|
source: 'crustdata',
|
|
data: testData,
|
|
});
|
|
|
|
const raw = await callOp('get_raw_data', {
|
|
slug: 'people/sarah-chen',
|
|
source: 'crustdata',
|
|
}) as any[];
|
|
expect(raw.length).toBeGreaterThanOrEqual(1);
|
|
// JSONB may come back as string or parsed object
|
|
const data = typeof raw[0].data === 'string' ? JSON.parse(raw[0].data) : raw[0].data;
|
|
expect(data.education).toBe('Stanford CS 2020');
|
|
expect(data.title).toBe('CEO');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Files (stub verification)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Files', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('file_list returns empty initially', async () => {
|
|
const files = await callOp('file_list', {}) as any[];
|
|
expect(files.length).toBe(0);
|
|
});
|
|
|
|
test('file_upload stores metadata + file_list shows it', async () => {
|
|
// Create a temp file
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-'));
|
|
const tmpFile = join(tmpDir, 'test-doc.pdf');
|
|
writeFileSync(tmpFile, 'fake pdf content');
|
|
|
|
try {
|
|
const result = await callOp('file_upload', {
|
|
path: tmpFile,
|
|
page_slug: 'people/sarah-chen',
|
|
}) as any;
|
|
expect(result.status).toBe('uploaded');
|
|
expect(result.storage_path).toContain('sarah-chen');
|
|
|
|
// Verify file_list
|
|
const files = await callOp('file_list', {}) as any[];
|
|
expect(files.length).toBe(1);
|
|
|
|
// Verify file_url returns URI format
|
|
const url = await callOp('file_url', { storage_path: result.storage_path }) as any;
|
|
expect(url.url).toContain('gbrain:files/');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
// Security-wave-3 regression: MCP/remote callers MUST be confined to cwd
|
|
// (Issue #139). Local CLI callers are unrestricted — different trust model.
|
|
test('file_upload rejects outside-cwd paths for remote (MCP) callers', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-ssrf-'));
|
|
const tmpFile = join(tmpDir, 'stealable.txt');
|
|
writeFileSync(tmpFile, 'sensitive');
|
|
|
|
try {
|
|
const op = operationsByName['file_upload'];
|
|
let threw = false;
|
|
try {
|
|
await op.handler(makeCtx({ remote: true }), {
|
|
path: tmpFile,
|
|
page_slug: 'people/sarah-chen',
|
|
});
|
|
} catch (e: any) {
|
|
threw = true;
|
|
expect(String(e.message || e)).toMatch(/within the working directory/i);
|
|
}
|
|
expect(threw).toBe(true);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Security: Query Bounds
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: file_list LIMIT enforcement', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('file_list with slug filter respects LIMIT 100', async () => {
|
|
const sql = getConn();
|
|
const testSlug = 'test-limit-slug';
|
|
|
|
// Create the parent page first (FK constraint on files.page_slug)
|
|
await sql`
|
|
INSERT INTO pages (slug, title, type, compiled_truth, frontmatter)
|
|
VALUES (${testSlug}, ${'Test Limit Page'}, ${'note'}, ${'body'}, ${'{}'}::jsonb)
|
|
ON CONFLICT (source_id, slug) DO NOTHING
|
|
`;
|
|
|
|
// Insert 150 file rows for the same slug
|
|
for (let i = 0; i < 150; i++) {
|
|
await sql`
|
|
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
|
|
VALUES (${testSlug}, ${'file-' + String(i).padStart(3, '0') + '.txt'}, ${testSlug + '/file-' + i + '.txt'}, ${'text/plain'}, ${100}, ${'hash-' + i}, ${'{}'}::jsonb)
|
|
ON CONFLICT (storage_path) DO NOTHING
|
|
`;
|
|
}
|
|
|
|
// Verify we inserted 150
|
|
const count = await sql`SELECT count(*) as cnt FROM files WHERE page_slug = ${testSlug}`;
|
|
expect(Number(count[0].cnt)).toBe(150);
|
|
|
|
// Call file_list with slug — should return at most 100
|
|
const files = await callOp('file_list', { slug: testSlug }) as any[];
|
|
expect(files.length).toBeLessThanOrEqual(100);
|
|
expect(files.length).toBe(100);
|
|
});
|
|
|
|
test('file_list without slug also respects LIMIT 100', async () => {
|
|
// The 150 rows from the previous test are still in the DB
|
|
const files = await callOp('file_list', {}) as any[];
|
|
expect(files.length).toBeLessThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Idempotency Stress
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Idempotency', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('double import produces no duplicates', async () => {
|
|
// First import
|
|
await importFixtures();
|
|
const stats1 = await callOp('get_stats') as any;
|
|
|
|
// Second import (identical content)
|
|
await importFixtures();
|
|
const stats2 = await callOp('get_stats') as any;
|
|
|
|
expect(stats2.page_count).toBe(stats1.page_count);
|
|
expect(stats2.chunk_count).toBe(stats1.chunk_count);
|
|
});
|
|
|
|
test('modify one fixture, reimport, only that page updates', async () => {
|
|
await importFixtures();
|
|
const engine = getEngine();
|
|
|
|
// Modify sarah-chen content
|
|
const modified = readFileSync(join(FIXTURES_PATH, 'people/sarah-chen.md'), 'utf-8')
|
|
.replace('Stanford CS', 'MIT CS');
|
|
|
|
const result = await importFromContent(engine, 'people/sarah-chen', modified, { noEmbed: true });
|
|
expect(result.status).toBe('imported');
|
|
|
|
// Other pages should have been skipped if reimported
|
|
const content = readFileSync(join(FIXTURES_PATH, 'people/marcus-reid.md'), 'utf-8');
|
|
const { parseMarkdown } = await import('../../src/core/markdown.ts');
|
|
const parsed = parseMarkdown(content, 'people/marcus-reid.md');
|
|
const result2 = await importFromContent(engine, parsed.slug, content, { noEmbed: true });
|
|
expect(result2.status).toBe('skipped');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Setup Journey (CLI subprocess tests)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Setup Journey', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL! });
|
|
|
|
test('gbrain init --non-interactive connects and initializes', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stdout).toContain('Brain ready');
|
|
}, 30_000);
|
|
|
|
test('gbrain import imports fixtures via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stdout).toContain('imported');
|
|
}, 60_000);
|
|
|
|
test('gbrain search returns results via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'search', 'NovaMind'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stdout.length).toBeGreaterThan(0);
|
|
}, 30_000);
|
|
|
|
test('gbrain stats shows page count via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'stats'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
}, 30_000);
|
|
|
|
test('gbrain health runs via CLI', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'health'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
}, 30_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Init Edge Cases
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Init Edge Cases', () => {
|
|
afterAll(teardownDB);
|
|
|
|
test('init --non-interactive without URL fails gracefully', () => {
|
|
const env = { ...process.env };
|
|
delete env.DATABASE_URL;
|
|
delete env.GBRAIN_DATABASE_URL;
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive'],
|
|
cwd: join(import.meta.dir, '../..'),
|
|
env,
|
|
timeout: 10_000,
|
|
});
|
|
expect(result.exitCode).not.toBe(0);
|
|
});
|
|
|
|
test('double init is idempotent', async () => {
|
|
await setupDB();
|
|
const conn = getConn();
|
|
const before = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
|
|
// Re-init
|
|
const { initSchema } = await import('../../src/core/db.ts');
|
|
await initSchema();
|
|
|
|
const after = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
expect(after[0].n).toBe(before[0].n);
|
|
});
|
|
|
|
test('init then import then re-init preserves pages', async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
const before = await callOp('get_stats') as any;
|
|
|
|
const { initSchema } = await import('../../src/core/db.ts');
|
|
await initSchema();
|
|
|
|
const after = await callOp('get_stats') as any;
|
|
expect(after.page_count).toBe(before.page_count);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Schema Idempotency
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Schema Idempotency', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('initSchema twice produces no errors and same object count', async () => {
|
|
const conn = getConn();
|
|
const tables1 = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
const indexes1 = await conn.unsafe(`SELECT count(*) as n FROM pg_indexes WHERE schemaname = 'public'`);
|
|
|
|
const { initSchema } = await import('../../src/core/db.ts');
|
|
await initSchema();
|
|
|
|
const tables2 = await conn.unsafe(`SELECT count(*) as n FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
const indexes2 = await conn.unsafe(`SELECT count(*) as n FROM pg_indexes WHERE schemaname = 'public'`);
|
|
|
|
expect(tables2[0].n).toBe(tables1[0].n);
|
|
expect(indexes2[0].n).toBe(indexes1[0].n);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Schema Diff Guard
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Schema Diff Guard', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('all expected tables exist', async () => {
|
|
const conn = getConn();
|
|
const tables = await conn.unsafe(`
|
|
SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
ORDER BY table_name
|
|
`);
|
|
const tableNames = tables.map((t: any) => t.table_name);
|
|
|
|
const expected = [
|
|
'config', 'content_chunks', 'files', 'ingest_log',
|
|
'links', 'page_versions', 'pages', 'raw_data',
|
|
'tags', 'timeline_entries',
|
|
];
|
|
for (const table of expected) {
|
|
expect(tableNames).toContain(table);
|
|
}
|
|
});
|
|
|
|
test('pgvector extension is installed', async () => {
|
|
const conn = getConn();
|
|
const ext = await conn.unsafe(`SELECT extname FROM pg_extension WHERE extname = 'vector'`);
|
|
expect(ext.length).toBe(1);
|
|
});
|
|
|
|
test('pg_trgm extension is installed', async () => {
|
|
const conn = getConn();
|
|
const ext = await conn.unsafe(`SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'`);
|
|
expect(ext.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Slug with Special Characters (Apple Notes fix)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Slug with Special Characters', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('imports files with spaces in filename', async () => {
|
|
const page = await callOp('get_page', { slug: 'apple-notes/2017-05-03-ohmygreen' }) as any;
|
|
expect(page).not.toBeNull();
|
|
expect(page.title).toBe('OhMyGreen');
|
|
expect(page.type).toBe('company');
|
|
});
|
|
|
|
test('imports files with parens in filename', async () => {
|
|
const page = await callOp('get_page', { slug: 'apple-notes/notes-march-2024' }) as any;
|
|
expect(page).not.toBeNull();
|
|
expect(page.title).toBe('March 2024 Notes');
|
|
});
|
|
|
|
test('search finds content from special-char files', async () => {
|
|
const results = await callOp('search', { query: 'OhMyGreen' }) as any[];
|
|
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
const slugs = results.map((r: any) => r.slug);
|
|
expect(slugs).toContain('apple-notes/2017-05-03-ohmygreen');
|
|
});
|
|
|
|
test('re-import of special-char files is idempotent', async () => {
|
|
const before = await callOp('get_stats') as any;
|
|
await importFixtures(); // second import
|
|
const after = await callOp('get_stats') as any;
|
|
expect(after.page_count).toBe(before.page_count);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// RLS Verification
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: RLS Verification', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL!, GBRAIN_DATABASE_URL: process.env.DATABASE_URL! });
|
|
|
|
// Seed a unique suffix per run so concurrent test DBs / crashed prior
|
|
// runs don't collide. All helper tables follow `gbrain_rls_regression_<suffix>`.
|
|
const suffix = `${process.pid}_${Date.now()}`;
|
|
|
|
test('RLS is enabled on every public table (no hardcoded allowlist)', async () => {
|
|
const conn = getConn();
|
|
const tables = await conn.unsafe(`
|
|
SELECT tablename, rowsecurity FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
`);
|
|
const noRls = tables.filter((t: any) => !t.rowsecurity);
|
|
// Some test DBs may not have BYPASSRLS privilege, so RLS might be skipped.
|
|
// If RLS was enabled at all (the common case against Docker postgres), EVERY
|
|
// public table must have it — no hardcoded IN-list exceptions.
|
|
if (tables.some((t: any) => t.rowsecurity)) {
|
|
expect(noRls.map((t: any) => t.tablename)).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test('current user role has BYPASSRLS', async () => {
|
|
const conn = getConn();
|
|
const rows = await conn.unsafe(`SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user`);
|
|
if (rows.length > 0) {
|
|
expect(rows[0].rolbypassrls).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('gbrain doctor fails with exit 1 when a public table is missing RLS', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_regression_${suffix}`;
|
|
try {
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
// Make sure RLS is actually off; CREATE TABLE default is off but be explicit.
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
|
|
// Init (idempotent) so the CLI has a config to read.
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls).toBeDefined();
|
|
expect(rls.status).toBe('fail');
|
|
expect(rls.message).toContain(tbl);
|
|
expect(rls.message).toContain('ALTER TABLE');
|
|
expect(result.exitCode).toBe(1);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
}
|
|
}, 60_000);
|
|
|
|
test('GBRAIN:RLS_EXEMPT comment with valid reason exempts a non-RLS public table', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_exempt_ok_${suffix}`;
|
|
try {
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'GBRAIN:RLS_EXEMPT reason=e2e test fixture, anon-readable ok'`);
|
|
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls.status).toBe('ok');
|
|
expect(rls.message).toContain('explicitly exempt');
|
|
expect(rls.message).toContain(tbl);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
}
|
|
}, 60_000);
|
|
|
|
test('GBRAIN:RLS_EXEMPT comment WITHOUT reason= still fails doctor', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_exempt_bad_${suffix}`;
|
|
try {
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
// Missing the `reason=<...>` segment — prefix alone is not enough.
|
|
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'GBRAIN:RLS_EXEMPT'`);
|
|
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls.status).toBe('fail');
|
|
expect(rls.message).toContain(tbl);
|
|
expect(result.exitCode).toBe(1);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
}
|
|
}, 60_000);
|
|
|
|
test('Non-exempt unrelated COMMENT on a no-RLS table still fails doctor', async () => {
|
|
const conn = getConn();
|
|
const tbl = `gbrain_rls_comment_${suffix}`;
|
|
try {
|
|
await conn.unsafe(`CREATE TABLE public.${tbl} (id int)`);
|
|
await conn.unsafe(`ALTER TABLE public.${tbl} DISABLE ROW LEVEL SECURITY`);
|
|
await conn.unsafe(`COMMENT ON TABLE public.${tbl} IS 'Regular docs comment, not an exemption'`);
|
|
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 20_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
const rls = parsed.checks.find((c: any) => c.name === 'rls');
|
|
expect(rls.status).toBe('fail');
|
|
expect(result.exitCode).toBe(1);
|
|
} finally {
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.${tbl}`);
|
|
}
|
|
}, 60_000);
|
|
|
|
// Regression test for the v24 self-healing guard. If an operator manually
|
|
// drops budget_ledger and/or budget_reservations (they are migration-only
|
|
// per v12, not in schema.sql, and the data is regenerable from resolver
|
|
// logs — so dropping them is a reasonable cleanup), v24 must NOT fail
|
|
// with 42P01. The information_schema.tables IF EXISTS guards around those
|
|
// two ALTERs let the migration skip them and continue.
|
|
//
|
|
// Without the guard, a brain with dropped budget_* tables would get stuck
|
|
// in an infinite retry loop: v24 fails → transaction rolls back →
|
|
// schema_version stays at prior value → next initSchema re-runs v24 →
|
|
// same failure forever.
|
|
test('v24 self-heals when budget_ledger + budget_reservations are missing', async () => {
|
|
const conn = getConn();
|
|
let priorVersion: string | null = null;
|
|
try {
|
|
// Capture current version so we can restore after the test.
|
|
const verRows = await conn.unsafe(`SELECT value FROM config WHERE key = 'version'`);
|
|
priorVersion = (verRows[0] as any)?.value ?? null;
|
|
|
|
// Simulate an operator who dropped the budget_* tables for any reason
|
|
// (cleanup, migration from an older gbrain, etc).
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.budget_ledger CASCADE`);
|
|
await conn.unsafe(`DROP TABLE IF EXISTS public.budget_reservations CASCADE`);
|
|
|
|
// Roll the version back to 23 so v24 re-runs on the next initSchema.
|
|
// UPSERT so this works whether the key exists or not.
|
|
await conn.unsafe(`
|
|
INSERT INTO config (key, value) VALUES ('version', '23')
|
|
ON CONFLICT (key) DO UPDATE SET value = '23'
|
|
`);
|
|
|
|
// Re-trigger initSchema via the CLI. With the guard, this should
|
|
// apply v24 cleanly and advance version to 24. Without the guard,
|
|
// this would error out with 42P01 and leave version at 23.
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 30_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const stderr = new TextDecoder().decode(result.stderr);
|
|
|
|
// Must succeed — no 42P01, no transaction rollback.
|
|
expect(result.exitCode).toBe(0);
|
|
expect(stderr + stdout).not.toMatch(/42P01|does not exist.*budget/i);
|
|
|
|
// 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);
|
|
|
|
// The tables stayed dropped (v12 didn't re-run because current=23 > 12
|
|
// was already true before this test ran). That's intentional — we're
|
|
// proving v24 doesn't require those tables to exist.
|
|
const tblRows = await conn.unsafe(`
|
|
SELECT tablename FROM pg_tables
|
|
WHERE schemaname = 'public'
|
|
AND tablename IN ('budget_ledger', 'budget_reservations')
|
|
`);
|
|
expect(tblRows.length).toBe(0);
|
|
} finally {
|
|
// Restore: recreate the budget_* tables (minimal schema — just enough
|
|
// to keep the rest of the test suite happy) and reset version.
|
|
// Mirror migration v12's CREATE TABLE IF NOT EXISTS exactly so any
|
|
// downstream test that touches these tables sees the original shape.
|
|
await conn.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS budget_ledger (
|
|
scope TEXT NOT NULL,
|
|
resolver_id TEXT NOT NULL,
|
|
local_date DATE NOT NULL,
|
|
reserved_usd NUMERIC(12,4) NOT NULL DEFAULT 0,
|
|
committed_usd NUMERIC(12,4) NOT NULL DEFAULT 0,
|
|
cap_usd NUMERIC(12,4),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (scope, resolver_id, local_date)
|
|
)
|
|
`);
|
|
await conn.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS budget_reservations (
|
|
reservation_id TEXT PRIMARY KEY,
|
|
scope TEXT NOT NULL,
|
|
resolver_id TEXT NOT NULL,
|
|
local_date DATE NOT NULL,
|
|
estimate_usd NUMERIC(12,4) NOT NULL,
|
|
reserved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'held'
|
|
)
|
|
`);
|
|
// Enable RLS on the recreated tables so the "every public table has
|
|
// RLS" assertion earlier in this block stays green if re-run.
|
|
await conn.unsafe(`ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY`);
|
|
await conn.unsafe(`ALTER TABLE budget_reservations ENABLE ROW LEVEL SECURITY`);
|
|
// Restore version so we don't leave the DB at a weird state for
|
|
// subsequent test blocks.
|
|
if (priorVersion !== null) {
|
|
await conn.unsafe(
|
|
`UPDATE config SET value = $1 WHERE key = 'version'`,
|
|
[priorVersion],
|
|
);
|
|
}
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Doctor Command
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Doctor Command', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
await importFixtures();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL!, GBRAIN_DATABASE_URL: process.env.DATABASE_URL! });
|
|
|
|
test('gbrain doctor exits 0 on healthy DB', () => {
|
|
// Init first so config exists for CLI
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
}, 60_000);
|
|
|
|
test('gbrain doctor --json produces valid JSON', () => {
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'doctor', '--json'],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 15_000,
|
|
});
|
|
const stdout = new TextDecoder().decode(result.stdout);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.status).toBeDefined();
|
|
expect(Array.isArray(parsed.checks)).toBe(true);
|
|
expect(parsed.checks.length).toBeGreaterThan(0);
|
|
for (const check of parsed.checks) {
|
|
expect(['ok', 'warn', 'fail']).toContain(check.status);
|
|
expect(typeof check.name).toBe('string');
|
|
expect(typeof check.message).toBe('string');
|
|
}
|
|
}, 30_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Parallel Import
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Parallel Import', () => {
|
|
afterAll(teardownDB);
|
|
|
|
const cliCwd = join(import.meta.dir, '../..');
|
|
const cliEnv = () => ({ ...process.env, DATABASE_URL: process.env.DATABASE_URL!, GBRAIN_DATABASE_URL: process.env.DATABASE_URL! });
|
|
|
|
function initCli() {
|
|
Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'init', '--non-interactive', '--url', process.env.DATABASE_URL!],
|
|
cwd: cliCwd, env: cliEnv(), timeout: 15_000,
|
|
});
|
|
}
|
|
|
|
// Store sequential baseline for comparison
|
|
let seqPageCount: number;
|
|
let seqChunkCount: number;
|
|
let seqPageSlugs: string[];
|
|
|
|
test('sequential baseline: import all fixtures', async () => {
|
|
await setupDB();
|
|
initCli();
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
seqPageCount = stats.page_count;
|
|
seqChunkCount = stats.chunk_count;
|
|
|
|
const pages = await callOp('list_pages', { limit: 200 }) as any[];
|
|
seqPageSlugs = pages.map((p: any) => p.slug).sort();
|
|
|
|
expect(seqPageCount).toBeGreaterThan(0);
|
|
expect(seqChunkCount).toBeGreaterThan(0);
|
|
}, 60_000);
|
|
|
|
test('parallel import with --workers 2 matches sequential page count', async () => {
|
|
await setupDB();
|
|
initCli();
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', '--workers', '2', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(seqPageCount);
|
|
}, 60_000);
|
|
|
|
test('parallel import has same chunk count (no duplicates)', async () => {
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.chunk_count).toBe(seqChunkCount);
|
|
});
|
|
|
|
test('parallel import has same page slugs', async () => {
|
|
const pages = await callOp('list_pages', { limit: 200 }) as any[];
|
|
const parSlugs = pages.map((p: any) => p.slug).sort();
|
|
expect(parSlugs).toEqual(seqPageSlugs);
|
|
});
|
|
|
|
test('no duplicate pages from concurrent writes', async () => {
|
|
const conn = getConn();
|
|
const dupes = await conn.unsafe(`
|
|
SELECT slug, count(*) as n FROM pages GROUP BY slug HAVING count(*) > 1
|
|
`);
|
|
expect(dupes.length).toBe(0);
|
|
});
|
|
|
|
test('no duplicate chunks from concurrent writes', async () => {
|
|
const conn = getConn();
|
|
const dupes = await conn.unsafe(`
|
|
SELECT page_id, chunk_index, count(*) as n
|
|
FROM content_chunks
|
|
GROUP BY page_id, chunk_index
|
|
HAVING count(*) > 1
|
|
`);
|
|
expect(dupes.length).toBe(0);
|
|
});
|
|
|
|
test('parallel import with --workers 4 also works', async () => {
|
|
await setupDB();
|
|
initCli();
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', '--workers', '4', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(seqPageCount);
|
|
expect(stats.chunk_count).toBe(seqChunkCount);
|
|
}, 60_000);
|
|
|
|
test('re-import with workers is idempotent', async () => {
|
|
// Import again on top of existing data
|
|
const result = Bun.spawnSync({
|
|
cmd: ['bun', 'run', 'src/cli.ts', 'import', '--no-embed', '--workers', '2', FIXTURES_PATH],
|
|
cwd: cliCwd,
|
|
env: cliEnv(),
|
|
timeout: 30_000,
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
|
|
const stats = await callOp('get_stats') as any;
|
|
expect(stats.page_count).toBe(seqPageCount);
|
|
expect(stats.chunk_count).toBe(seqChunkCount);
|
|
}, 60_000);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// Performance Baselines
|
|
// ─────────────────────────────────────────────────────────────────
|
|
|
|
describeE2E('E2E: Performance Baselines', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
afterAll(teardownDB);
|
|
|
|
test('import + search + link performance', async () => {
|
|
const [_, importMs] = await time(importFixtures);
|
|
|
|
const searchTimes: number[] = [];
|
|
for (const q of ['NovaMind', 'hybrid search', 'Stanford', 'investor', 'compiled truth']) {
|
|
const [__, ms] = await time(() => callOp('search', { query: q }));
|
|
searchTimes.push(ms);
|
|
}
|
|
|
|
const [___, linkMs] = await time(async () => {
|
|
await callOp('add_link', { from: 'people/sarah-chen', to: 'companies/novamind' });
|
|
await callOp('get_backlinks', { slug: 'companies/novamind' });
|
|
});
|
|
|
|
searchTimes.sort((a, b) => a - b);
|
|
const p50 = searchTimes[Math.floor(searchTimes.length * 0.5)];
|
|
const p99 = searchTimes[searchTimes.length - 1];
|
|
|
|
console.log('\n Performance Baselines:');
|
|
console.log(` Import 13 fixtures: ${importMs.toFixed(0)}ms`);
|
|
console.log(` Search p50: ${p50.toFixed(0)}ms`);
|
|
console.log(` Search p99: ${p99.toFixed(0)}ms`);
|
|
console.log(` Link + backlink: ${linkMs.toFixed(0)}ms`);
|
|
});
|
|
});
|