Skip to content

feat(oauth): federated_read column + AuthInfo.allowedSources#876

Closed
toilalesondev wants to merge 7 commits into
garrytan:masterfrom
toilalesondev:feat/oauth-federated-read
Closed

feat(oauth): federated_read column + AuthInfo.allowedSources#876
toilalesondev wants to merge 7 commits into
garrytan:masterfrom
toilalesondev:feat/oauth-federated-read

Conversation

@toilalesondev

@toilalesondev toilalesondev commented May 11, 2026

Copy link
Copy Markdown

Summary

Replaces the oauth_clients.source_id NULL = super-reader pattern with explicit, per-client read scope. Each row now carries both a single write target (source_id NOT NULL) and a read scope (federated_read TEXT[]), giving fine-grained federation without the silent super-reader trap.

Stacked on #861 (read-side source isolation v2). Targets the same base branch.

Why

The single-column source_id shape conflates two concerns:

  • source_id = 'X' → writes go to X and reads are scoped to X
  • source_id IS NULL → super-reader (reads everything)

Real federation needs the middle ground: write to ONE source, read from N. Today the only way to give a client that shape is source_id IS NULL, which also grants unconstrained write privilege and silently survives source deletes (FK was ON DELETE SET NULL).

This PR splits write target and read scope into two columns, lets dept-style clients federate cleanly across own + shared + wecare, and closes the silent-super-reader-on-source-delete leak.

Schema changes

Migrations v47–v52, applied across src/core/migrate.ts, src/core/schema-embedded.ts (fresh postgres installs), and src/core/pglite-schema.ts (fresh pglite installs).

Version Change
v47 ALTER TABLE oauth_clients ADD COLUMN federated_read text[] (nullable)
v48 Backfill federated_read = ARRAY[source_id] for non-NULL source_id rows; RAISE EXCEPTION if any NULL source_id row exists (refuse to repair silently — operator must fix via audit skill before retry)
v49 ALTER COLUMN source_id SET NOT NULL
v50 ALTER COLUMN federated_read SET NOT NULL + CHECK (source_id = ANY(federated_read))
v51 Drop FK + recreate with ON DELETE RESTRICT (was SET NULL — closed silent super-reader promotion)
v52 CREATE INDEX ON oauth_clients USING GIN (federated_read) (read-scope lookups)

AuthInfo gains allowedSources?: string[] populated from federated_read. Engines prefer allowedSources (ANY()) over scalar sourceId (=) when both are present. Write paths remain scoped to scalar sourceId.

Code path wiring

oauth_clients.federated_read
    └─> AuthInfo.allowedSources               (src/core/oauth-provider.ts:verifyAccessToken)
          ├─> OAuth path                      (src/commands/serve-http.ts /mcp handler)
          └─> Legacy access_tokens path       (src/mcp/http-transport.ts validateToken)
                └─> DispatchOpts              (src/mcp/dispatch.ts)
                      └─> readScopeOpts       (src/core/operations.ts)
                            └─> sourceFilter  (postgres-engine.ts + pglite-engine.ts)

Both transport paths forward the new field. Engine read methods prefer allowedSources when present.

Verification

Verified live with a dept-hr OAuth client (source_id=dept-hr, federated_read={dept-hr,wecare,shared}) hitting an MCP HTTP server on :8765. 8/8 probes pass:

# Call Expected Result
1 get_page own (dept-hr) allow
2 get_page own (dept-hr) allow
3 get_page federated (shared/policy/...) allow
4 get_page federated (shared/federated-mind) allow
5 get_page federated (shared/incidents/...) allow
6 get_page cross-source (augustus/wecare/roster) page_not_found
7 get_page cross-source (augustus/resolver) page_not_found
8 get_page cross-source (robin/nonexistent) page_not_found

Same-slug-across-sources resolves within scope (no cross-source bleed): a slug present in both dept-hr and dept-logistics correctly returned the dept-hr row for the dept-hr token.

Migration safety

  • v48 backfill is idempotent (re-runs no-op on already-backfilled rows).
  • v48 explicit RAISE EXCEPTION on any pre-migration NULL source_id row. The exception message names the audit skill (gbrain-oauth-client-binding) so the operator knows where to look. Refusing > silent repair.
  • v51 FK swap runs in the same transaction as v50's NOT NULL flip — pg won't complain about SET NULL on NOT NULL mid-migration.
  • v52 GIN index uses IF NOT EXISTS.

Mid-PR fix

The second commit (af5bf48) fixes a wiring miss caught during the live rollout: serve-http.ts OAuth path was setting sourceId: tokenSourceId in dispatchToolCall opts but dropping allowedSources. Result: OAuth-token callers silently fell back to scalar-only filter, ignored federated_read, and got page_not_found on federated reads they should have allowed.

Class-level lesson: any new AuthInfo field has to be wired through two independent transport call sitesserve-http.ts (OAuth) and http-transport.ts (legacy access_tokens). No DRY shortcut exists as of v0.32.0. Documented in the deployer's runbook so the next field doesn't ship half-wired.

Files changed

  • src/core/migrate.ts — v47–v52 migrations
  • src/core/schema-embedded.ts — fresh postgres install schema
  • src/core/pglite-schema.ts — fresh pglite install schema
  • src/core/types.tsAuthInfo.allowedSources?: string[]
  • src/core/oauth-provider.tsverifyAccessToken SELECT + return
  • src/core/operations.tsreadScopeOpts helper threads allowedSources
  • src/core/postgres-engine.tssourceFilter prefers allowedSources
  • src/core/pglite-engine.ts — same, for pglite
  • src/mcp/dispatch.tsDispatchOpts.allowedSources?: string[]
  • src/mcp/http-transport.ts — legacy path forwards from permissions
  • src/commands/serve-http.ts — OAuth path forwards from authInfo

Backward compatibility

  • Existing single-source clients keep working (their federated_read is backfilled to ARRAY[source_id], behavior unchanged).
  • Tokens issued before v48 still verify; AuthInfo.allowedSources is populated from the live oauth_clients row at verify time (no token re-mint required).
  • Engines fall back to scalar sourceId filter if allowedSources is absent (older engines / dispatch paths from forks not yet on v0.32.x stay functional).

Notes

  • Tests: not adding new test scaffolding here; this codebase doesn't have a test framework set up for the MCP HTTP path. Verified empirically via the probe matrix above. Happy to add fixture-based tests if the project wants them.
  • The audit + probe scripts I run on my fleet live in a private skill; the SQL invariants are documented in the migration comments. If the project wants a gbrain CLI verb like gbrain auth audit that runs the equivalent check, happy to follow up with a separate PR.

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

toilaleson and others added 7 commits May 11, 2026 13:55
Builds on the baseline commit. Adds the actual enforcement and the auth
wire-up that populates AuthInfo.sourceId, so OAuth-authenticated MCP
clients can no longer read pages belonging to other sources.

Engine layer (the WHERE clauses the previous commit left missing):
- core/postgres-engine.ts: searchKeyword / searchKeywordChunks / searchVector
  now apply `AND pages.source_id = $N` when ctx.sourceId is set
- core/search/hybrid.ts: thread opts.sourceId into the searchOpts literal
  before calling engine.searchKeyword/searchVector. Previously this
  function rebuilt searchOpts without the field, silently dropping the
  filter even after the engine learned to honor it. This was the Tier 3
  leak (`query` op) that survived the engine-level fix.

Auth layer (populates the field):
- core/migrate.ts: migration v47 adds oauth_clients.source_id (FK to sources,
  ON DELETE SET NULL)
- core/schema-embedded.ts + core/pglite-schema.ts: same column in the
  fresh-install schema
- core/oauth-provider.ts: verifyAccessToken JOINs oauth_clients and
  propagates source_id into AuthInfo.sourceId
- mcp/http-transport.ts: legacy bearer-token validateToken path also
  extracts source_id and federated_read claims
- commands/serve-http.ts: tokenSourceId reads the formal AuthInfo.sourceId
  field instead of the previous always-'default' cast

Semantics:
- oauth_clients.source_id SET  → strict isolation, engine WHERE-filters
- oauth_clients.source_id NULL → federated/super-reader, no filter
  (matches stdio CLI behavior — unscoped = full reach)

Pre-fix, every OAuth client silently collapsed onto source 'default'
because the field was planned but never wired, and the engine ignored
the param even when callers passed it. This commit closes both halves.

Verified end-to-end against live shared Postgres with three test clients
(scoped-empty / scoped-populated / NULL-federated) across all four read
surfaces: list_pages, search, query (hybrid), get_page cross-source.
The dispatch chain (verifyAccessToken → ctx.sourceId → opts.sourceId)
was wired correctly but both engine implementations (postgres-engine and
pglite-engine) silently dropped the filter, returning unscoped pages.
Live stress test confirmed: HR-scoped token returned 50 rows from other
sources, including a 'shared/' handoff page it should never see.

This patch:
- adds sourceId to PageFilters type (src/core/types.ts)
- threads WHERE p.source_id = $sourceId into postgres-engine.listPages
- threads the same into pglite-engine.listPages

Re-validated end-to-end against live MCP HTTP server with three OAuth
tokens (scoped, scoped, federated): scoped tokens now return only their
own source; NULL/federated tokens still see cross-source results, as
intended by the v0.31.4 architecture.

Reported-and-verified-by: Augustus (WeCare)
The image-similarity branch passed sourceId to engine.searchVector but
the main hybridSearch call path silently dropped it. hybridSearch
already accepted+threaded the param internally; the op handler just
never set it.
The traverse_graph operation leaked page slug + title + type metadata
across source boundaries even when get_page correctly sealed the page.
A scoped HR token could discover an Augustus or robin page exists by
seeding the graph traversal — it'd return depth=0 with the title visible.

Same engine-side gap as list_pages and query (fixed earlier in this PR):
the handler threaded ctx.sourceId but the engine SQL ignored it.

Fix scopes every page-touching JOIN/FROM in the recursive CTE for both
graph methods on both engines:

- traverseGraph: seed + recursive step + inner jsonb_agg edge subquery
- traversePaths: seed + recursive step + outer projection, for all
  three direction branches (in, out, both)

Same canonical pattern as resolveSlugs: `AND (${!scoped} OR
<alias>.source_id = ${sourceId})`. NULL/undefined sourceId =
federated/super-reader (matches the rest of this PR's contract).

Also threads ctx.sourceId in the resolve_slugs handler — was already
the engine-side accepting the param but the handler wasn't passing it.

Files: engine.ts (interface), postgres-engine.ts, pglite-engine.ts,
operations.ts (handler).

Verified live against Supabase: HR token traversing a robin-source
handoff page now returns 0 nodes; LOG token traversing an Augustus
page returns its own neighborhood; SYS token (federated, source_id
NULL) sees everything. No false-negatives on get_page / list_pages /
query / resolve_slugs regression checks.
Replaces the `source_id NULL = super-reader` pattern with explicit read
scope. Each oauth_clients row now has:

  - source_id TEXT NOT NULL          → write target (single source)
  - federated_read TEXT[] NOT NULL   → read scope (multi-source)
                                       with CHECK (source_id = ANY(federated_read))
  - FK ON DELETE RESTRICT (was SET NULL — closed the silent super-reader
    promotion on source delete)

Schema migration v47-v52 (postgres + pglite + embedded), idempotent:
  v47: add federated_read text[] (nullable)
  v48: backfill federated_read = ARRAY[source_id] for non-null source_id;
       sentinel-raise on any NULL source_id row (refuses to repair silently)
  v49: ALTER source_id SET NOT NULL
  v50: ALTER federated_read SET NOT NULL + CHECK constraint
  v51: drop FK + recreate with ON DELETE RESTRICT
  v52: index on federated_read GIN (read-scope lookups)

Read-path wiring:
  - verifyAccessToken: SELECT pulls source_id + federated_read, returns
    AuthInfo.{sourceId, allowedSources}
  - dispatch.ts:DispatchOpts accepts allowedSources
  - operations.ts:readScopeOpts threads it into engine reads
  - postgres-engine + pglite-engine sourceFilter: prefer
    allowedSources (ANY()) over scalar sourceId (=) when present
  - http-transport.ts: legacy access_tokens path forwards allowedSources
    from permissions JSON via AuthResult

Write-path remains scoped to AuthInfo.sourceId (single target).

Verified live on Augustus's :8765 with dept-hr OAuth token:
  - OWN dept-hr pages → allow
  - FED shared/* pages (3 distinct) → allow
  - DENY augustus/wecare/* + augustus/* + robin/* → page_not_found
  - Same-slug-across-sources resolves within scope (no cross-source bleed)

Refs: gbrain-oauth-client-binding skill § Schema-evolution gotchas.
Depends on #861 (source-isolation v2) — same base branch.
serve-http.ts /mcp handler (OAuth path via requireBearerAuth) was setting
`sourceId: tokenSourceId` in dispatchToolCall opts but dropping
`allowedSources` on the floor. The legacy http-transport.ts path was
already wiring it correctly via validateToken→AuthResult.

Result: OAuth-token callers fell back to scalar source_id filter only,
ignored federated_read entirely, and got page_not_found on every
federated source they should have been able to read.

Caught live during federated-read rollout 2026-05-11.

Class-level pitfall (documented in skill gbrain-oauth-client-binding
§ Schema-evolution gotchas #0): any new AuthInfo field has to be wired
through TWO independent transport call sites — OAuth (serve-http.ts)
AND legacy access-tokens (http-transport.ts). No DRY shortcut exists
as of v0.32.0.
embedAllStale grouped stale chunks by slug only, then called
getChunks(slug) without sourceId. The engine defaulted to source='default'
and threw 'Page not found' for any stale chunk in a non-default source.

In single-source brains this never surfaces. In federated brains (multiple
sources sharing one Postgres) every stale embed in a non-default source
silently failed with 'Embedded 0 chunks'.

Fix:
  - listStaleChunks now returns p.source_id alongside slug
  - StaleChunkRow type gains source_id field
  - embedAllStale groups by (source_id, slug) composite key
  - getChunks / upsertChunks called with { sourceId } from stale row
  - error message includes source for clarity

Tested: 46 chunks across 17 pages embedded in one pass, spanning
shared / wecare / augustus / racer sources. Zero 'Page not found' errors.

Co-authored-by: Robin <robin@hermes>
garrytan added a commit that referenced this pull request May 15, 2026
…ederated_read + 3 more (#996)

* fix(mcp): skip stdin EOF handlers when MCP_STDIO=1

OpenClaw's bundle-mcp gateway and similar wrappers pipe the JSON-RPC
handshake on stdin then close their stdin half. Pre-fix, both stdin
'end' and 'close' listeners (server.ts:65-66 and serve.ts:204-206)
treated this as a permanent disconnect and shut the server down before
the first tool call arrived.

Guard both sites with `process.env.MCP_STDIO !== '1'`. Signal handlers
(SIGTERM/SIGINT/SIGHUP), transport.onclose, and the parent-process
watchdog still cover legitimate shutdown paths. The serve.ts site
threads the env read through an injectable `mcpStdio?: boolean` on
ServeOptions so tests stay isolated (no process.env mutation per
scripts/check-test-isolation.sh R1).

Tests: 3 new cases in test/serve-stdio-lifecycle.test.ts pin the
guard's invariants — mcpStdio=true must NOT trigger shutdown on stdin
EOF, signals must still drive shutdown with mcpStdio=true, and
mcpStdio=false (default) preserves existing CLI behavior. 25/25 pass.

Origin: PR #870.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): honor token_endpoint_auth_method=none for PKCE public clients

RFC 7591 §3.2.1: when a DCR client declares
token_endpoint_auth_method="none" (PKCE-only public clients like Claude
Code, Cursor), the authorization server MUST NOT issue a client_secret.
Pre-fix, registerClient unconditionally minted a secret, and the MCP
SDK's clientAuth middleware then rejected valid public-client flows on
/token because it expected client.client_secret to match.

Three changes to src/core/oauth-provider.ts:registerClient:

  - Gate clientSecret generation on isPublicClient = (auth_method === 'none').
    Public clients store client_secret_hash = NULL.
  - Omit client_secret from the response payload for public clients.
    Confidential clients (default client_secret_post and explicit
    client_secret_basic) keep their existing one-time-reveal shape.
  - Normalize NULL secret_hash to JS undefined in getClient so SDK
    middleware (which checks client.client_secret === undefined, not
    === null) correctly identifies public clients and skips the
    secret-comparison branch on /token.

Schema is already permissive (client_secret_hash TEXT, no NOT NULL on
both src/schema.sql and src/core/pglite-schema.ts) — no migration
needed.

Tests: 5 new cases in test/oauth.test.ts pin:
  - public client → no client_secret in response (#11 from plan)
  - default auth_method → secret unchanged (regression guard)
  - explicit client_secret_post → secret unchanged
  - getClient NULL→undefined normalization
  - PKCE full /authorize → /token end-to-end with no secret (#15 from plan)

69/69 oauth.test.ts cases pass. typecheck clean.

Origin: PR #909.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(serve-http): --bind HOST, default to loopback (127.0.0.1)

Adds `gbrain serve --http --bind <interface>` to control which network
interface the HTTP MCP server listens on. Default flipped from
`0.0.0.0` (pre-v0.34) to `127.0.0.1` (v0.34.0+).

Why the flip: gbrain's primary use case is a personal-knowledge brain on
a laptop. The previous default exposed brains on every interface — one
accidental `--http` invocation away from publishing the brain to a LAN.
Server operators who need remote access pass `--bind 0.0.0.0` (or a
specific interface). Codex's outside-voice on the original PR #864
correctly flagged that the additive flag wasn't actually the fix; the
default needed to change for the safety claim to hold.

If `--public-url` is set but `--bind` is unset, runServeHttp prints a
loud stderr WARN at startup recommending `--bind 0.0.0.0`. Declaring a
public URL while quietly binding loopback is almost always a
misconfiguration; we want the operator to see it on first start, not
silently fail remote requests.

Startup banner now includes a `Bind:` row so the listening interface is
visible alongside Port / Engine / Issuer.

Origin: PR #864, extended with D11 (default flip) per /plan-eng-review
codex outside-voice review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): seal source-isolation leak on read path (P0)

Pre-fix, an authenticated OAuth MCP client scoped to source-A could
enumerate source-B pages via six read-side ops: search, query (text
AND image paths), list_pages, traverse_graph, and find_experts. The
v0.31.8 source-scoping pattern shipped through dispatch.ts but the op
handlers never threaded ctx.sourceId into their engine calls, and
hybridSearch.ts:223's explicit SearchOpts rebuild dropped sourceId
even when callers passed it.

Sealing the leak:

  - src/core/operations.ts adds sourceScopeOpts(ctx), the canonical
    precedence ladder: ctx.auth.allowedSources (federated) wins over
    ctx.sourceId (scalar) wins over nothing. Threaded into all 5
    read-side op handlers + the query-image-path searchVector call
    (the 6th leak surface codex caught in plan review).

  - src/core/search/hybrid.ts:223 now threads sourceId + sourceIds
    fields through the inner SearchOpts rebuild. The explicit pick
    shape is preserved (HNSW inner-CTE ordering depends on it) but
    extended.

  - src/core/types.ts adds sourceIds?: string[] to SearchOpts +
    PageFilters (D9: federated read needs array-shaped engine filter
    or fan-out; array wins for hot retrieval).

  - src/core/operations.ts AuthInfo gains sourceId + allowedSources
    (D2: identity surface symmetric with the federated_read column
    #876 will add).

  - Both engines now apply WHERE source_id = $N (scalar) or = ANY($N::text[])
    (array) at the SQL layer for searchKeyword, searchKeywordChunks,
    searchVector, listPages, traverseGraph, traversePaths. Array form
    wins when both are set. The searchVector filter pushes into the
    inner HNSW CTE (codex flagged this placement during plan review).

  - traverseGraph + traversePaths signatures gain opts.sourceId +
    opts.sourceIds; engine.ts interface updated.

  - findExperts (the whoknows op, D3 5th leak surface) accepts
    sourceId + sourceIds and threads them into its internal
    hybridSearch call. PR #861 was authored before v0.33 shipped so
    this op wasn't covered in the original PR.

Auth wiring:

  - GBrainOAuthProvider.verifyAccessToken populates AuthInfo.sourceId
    from oauth_clients.source_id. JOIN guarded by isUndefinedColumnError
    so pre-v55 brains degrade to legacy projection rather than refusing
    every token verification.

  - GBrainOAuthProvider.registerClientManual gains a sourceId
    parameter (defaults to 'default'). DCR registerClient also sets
    source_id='default' on the inserted row.

  - serve-http.ts:929 cleanup: AuthInfo.sourceId is now a real typed
    field. The cast + GBRAIN_SOURCE env fallback chain is gone (D13).
    Legacy bearer tokens default to 'default' source in
    verifyAccessToken.

  - http-transport.ts (legacy access_tokens path) threads
    sourceId='default' through DispatchOpts so v0.22.7 callers stay
    source-scoped.

  - auth.ts CLI adds --source flag to gbrain auth register-client.

Migration v55 (D10 + D13):

  - ALTER TABLE oauth_clients ADD COLUMN source_id TEXT (nullable).
  - Backfill UPDATE source_id = 'default' WHERE source_id IS NULL —
    preserves v0.33 effective behavior verbatim for legacy clients.
  - ADD CONSTRAINT FK ... REFERENCES sources(id) ON DELETE SET NULL,
    wrapped in DO block so re-runs against fresh-install brains (where
    the FK already lives inline in SCHEMA_SQL) no-op cleanly.
  - CREATE INDEX idx_oauth_clients_source_id WHERE source_id IS NOT NULL
    for the verifyAccessToken JOIN.
  - GBRAIN_ACCEPT_SILENT_WIDEN env-flag wired through the runner via
    SET LOCAL gbrain.accept_silent_widen — reserved for future migrations
    that hit the silent-widen footgun codex flagged. This migration
    doesn't need it (column is brand new; no pre-existing stale values
    possible by definition).
  - src/core/pglite-schema.ts + src/schema.sql include the column +
    FK + index inline for fresh installs.

Tests: new test/e2e/source-isolation-pglite.test.ts with 13 regression
cases — one per leak surface (search/list_pages/traverse/etc.) plus
explicit AuthInfo.sourceId and AuthInfo.allowedSources op-handler
threading checks. Full unit suite: 6034 pass / 0 fail. PGLite
initSchema time dropped from 2.4s to 850ms after consolidating v55's
DO blocks (multiple DO blocks were slow on PGLite; one DO block for
the FK install only is fine).

Origin: PR #861 + plan-eng-review decisions D2/D3/D4/D9/D10/D13 + F2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gateway): multimodal embedding for openai-compatible providers

Pre-fix, embedMultimodal hardcoded a recipe.id === 'voyage' branch and
threw AIConfigError for every other recipe. Multimodal-capable providers
fronted by LiteLLM (or any openai-compatible proxy) were unreachable
even when the operator had wired up the model.

The fix:

  - src/core/ai/gateway.ts adds embedMultimodalOpenAICompat() that
    POSTs to the standard /embeddings endpoint with content arrays
    carrying image_url entries. Routing comes from the existing
    recipe.implementation switch — Voyage stays on its own
    /multimodalembeddings path; every other openai-compatible recipe
    flows through the new helper.

  - src/core/ai/recipes/litellm-proxy.ts declares
    supports_multimodal: true so embedMultimodal accepts the recipe.
    No multimodal_models allow-list: LiteLLM is a passthrough proxy
    and the user owns model-id selection; provider rejection (400 from
    upstream) is the right enforcement layer there. Voyage's static
    allow-list shape stays unchanged (its 12 models share
    supports_multimodal but only one is multimodal-capable).

  - D12 runtime dimension validation: the new helper checks the
    returned vector length against the recipe's declared default_dims
    (preferred) or the brain's embedding_dimensions config. Mismatch
    throws AIConfigError with model id + observed + expected so the
    operator can swap models or rebuild the column. Pre-fix, a
    wrong-dim response would surface as a cryptic pgvector
    "vector dimension mismatch" at INSERT time.

  - Auth resolution routes through the existing defaultResolveAuth
    helper so optional-auth recipes (LiteLLM proxy with no
    LITELLM_API_KEY) and required-auth recipes both share one code
    path. Optional-auth sends "Authorization: Bearer unauthenticated"
    which servers like Ollama / llama-server ignore but the SDK
    contract requires.

Tests: 11 new cases in test/openai-compat-multimodal.test.ts cover
happy-path, multi-input batching, unauthenticated proxy, D12 dim
mismatch + default-dim fallback, 401 / 400 / malformed-JSON / non-array
error paths, and an explicit Voyage-regression test pinning that the
new openai-compat route doesn't accidentally hijack the Voyage path.
All 41 multimodal-related tests pass (existing voyage suite + new).
typecheck clean.

Origin: PR #875 + plan-eng-review D12 (runtime dim validation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(oauth): federated_read read scope (#876)

Pre-fix, OAuth clients had a single source-scope axis (source_id, added
in v55). A client could either write+read one source OR be a super-reader
across all sources (via NULL source_id). There was no middle ground —
WeCare-style L3 dept clients that need to write to dept-x but read
dept-x + parent canon + shared canon had no expression.

#876 adds federated_read TEXT[] as an orthogonal read-scope axis. source_id
is the WRITE authority; federated_read is the READ authority. They default
to matching values (read scope == write scope, the pre-v0.34 default)
when a client is registered without an explicit federated read list.

Migrations v56-v60 (six new migrations on top of v55):

  - v56: ALTER TABLE ... ADD COLUMN federated_read TEXT[] NOT NULL DEFAULT '{}'.
  - v57 (F5): explicit CASE backfill so source_id IS NULL → '{}' (not an
    array containing NULL — codex caught this ambiguity during plan review).
  - v58: post-backfill validation. Fails loud if any row's source_id isn't
    in its federated_read array, pointing at a logic bug in v57 if fired.
  - v59: flip the source_id FK from ON DELETE SET NULL to ON DELETE
    RESTRICT now that federated_read provides the alternative scope-loss
    path. Pre-flip, deleting a source could silently widen any oauth_client
    to super-reader; post-flip, source delete is refused if any client
    references it (operator must revoke/re-scope first).
  - v60: GIN index on federated_read for array-containment queries.

Auth wiring:

  - GBrainOAuthProvider.verifyAccessToken JOINs c.federated_read and
    populates AuthInfo.allowedSources. Pre-v56 / pre-v55 brains degrade
    via the existing isUndefinedColumnError fallback chain.
  - registerClientManual gains a federatedRead?: string[] parameter
    (defaults to [sourceId]).
  - DCR registerClient sets source_id='default' + federated_read=['default']
    on the inserted row.
  - auth.ts CLI adds --federated-read SRC1,SRC2,... flag. The
    register-client output now prints "Federated reads:" so operators
    confirm the scope they set.

Engines consume the federated array through the SearchOpts.sourceIds /
PageFilters.sourceIds field that #861 added (no engine changes here — the
plumbing was D9). sourceScopeOpts in operations.ts already prefers the
auth.allowedSources array over scalar ctx.sourceId when set.

Test seam:
  - test/book-mirror.test.ts now spawns the CLI with GBRAIN_HOME pointed
    at a tempdir so the test isn't sensitive to the developer's local
    ~/.gbrain/config.json. Pre-fix the test could silently inherit a real
    Postgres connection and hang past the default 5s test timeout. Fresh
    GBRAIN_HOME → "No brain configured" → exit 1 in <1s.
  - test/e2e/source-isolation-pglite.test.ts gains one more regression
    case: AuthInfo.allowedSources = [] (explicit empty) MUST NOT widen
    scope to "all sources" — the silent-widen footgun precedence ladder.
  - test/openai-compat-multimodal.test.ts is part of the wave's commits
    via the migrate.ts changes that bump the schema chain. typecheck-only
    fix on a captured-auth type was already in #875's tree.

6045 unit tests pass / 0 fail. typecheck clean. PGLite initSchema runs
v55-v60 in ~786ms total (within the test-harness budget for tests using
the canonical beforeAll engine pattern).

Origin: PR #876 + plan-eng-review F5 (CASE backfill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.34.0.0: MCP fix wave (#870 #909 #864 #861 #875 #876)

VERSION + package.json + CHANGELOG bump for the six-PR MCP fix wave.
Schema chain extends from v54 → v60; oauth_clients gains source_id +
federated_read columns; auth'd MCP clients now stay inside their scope
across all read-side ops; PKCE-only DCR works; --bind defaults to
loopback; LiteLLM multimodal embedding ships.

Contributed by @Hansen1018 (#870), @ding-modding (#909), @DukeDawg
(#864), @toilalesondev (#861 + #876), @yoelgal (#875).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.34.0.0

Sync README, CLAUDE.md, SECURITY.md, docs/architecture/topologies.md,
and docs/mcp/DEPLOY.md to reflect the v0.34.0.0 MCP fix wave:

- README: document --bind HOST default (loopback), --source +
  --federated-read register-client flags, PKCE public-client gate
- SECURITY.md: note loopback-by-default for serve --http, update the
  trust-proxy contract to point at the new default
- CLAUDE.md: annotate operations.ts (sourceScopeOpts helper),
  oauth-provider.ts (verifyAccessToken JOIN + PKCE public clients),
  serve-http.ts (--bind flag), gateway.ts (openai-compat multimodal +
  dim validation), mcp/server.ts (MCP_STDIO guard), auth.ts (--source
  + --federated-read), migrate.ts (v58-v63 chain), engine.ts
  (sourceIds field). Add 4 new test-file entries for
  source-isolation-pglite, openai-compat-multimodal,
  serve-stdio-lifecycle, oauth.test.ts PKCE cases
- docs/architecture/topologies.md: source-scoped register-client
  example, --bind 0.0.0.0 for thin-client host setup
- docs/mcp/DEPLOY.md: --bind explanation in the ngrok section,
  source-scoped client recipe
- llms-full.txt: regenerated per the CLAUDE.md-edit chaser rule

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump v0.34.0.0 → v0.34.1.0

Renumbering the MCP fix wave from v0.34.0.0 to v0.34.1.0 so the
release slot lands between master's v0.33.2.1 and the next minor.

Touches every release-artifact mention:
- VERSION: 0.34.0.0 → 0.34.1.0
- package.json: same
- CHANGELOG.md header + "To take advantage" block
- CLAUDE.md key-files annotations (8 entries that document this wave)
- llms-full.txt (regen from CLAUDE.md)
- README.md / SECURITY.md / docs/architecture/topologies.md / docs/mcp/DEPLOY.md
- Wave code-comment markers ("// v0.34.0 (#NNN):" → "// v0.34.1 (#NNN):")

Test files renamed alongside since they were committed with the wave.

Commit subjects on the original 6 PR commits + the v0.34.0.0 bump
commit (4f533c76b47db7) intentionally NOT rewritten — those are
history. `git log` finds the implementation by message subject, not by
version tag.

6275 unit tests pass, typecheck clean, migration chain v58-v63 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@garrytan

Copy link
Copy Markdown
Owner

Thank you for this work @toilalesondev — closing as already merged into master via PR #996 / v0.34.1.0.

Your federated_read column design landed verbatim: migrations v60-v65 add oauth_clients.federated_read TEXT[], the FK flip to ON DELETE RESTRICT, the GIN index, and AuthInfo.allowedSources is now the typed source of truth on the read path. The v0.34.1.0 CHANGELOG credits you. Closing this PR for cleanup only — the work is in production.

See CLAUDE.md "v0.34.1.0 (#876 + #861)" for the full integration notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants