Skip to content

feat(vendo,store): one tenant brings its own tools, and only that tenant's users get them - #1473

Merged
yousefh409 merged 13 commits into
mainfrom
yousefh409/ingest-s4-tenants
Aug 18, 2026
Merged

feat(vendo,store): one tenant brings its own tools, and only that tenant's users get them#1473
yousefh409 merged 13 commits into
mainfrom
yousefh409/ingest-s4-tenants

Conversation

@yousefh409

@yousefh409 yousefh409 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Part of a 4-PR stack (main → S1 #1464 → S2 #1465 → S3 #1472S4 (this)). The merge unit is the tip — this PR is not meant to land alone.

What it adds

One tenant brings its own tools, and only that tenant's users get them. A host registers an org's own MCP server or OpenAPI spec at runtime — no redeploy, no console, no UI.

Isolation is structural, not a filter. Each org that has registered anything gets its OWN actions registry, built over the shared connectors plus its own; a request is served the registry its asserted memberships select. Another tenant's connector is not withheld from that registry — it was never in it, so there is no filter to get wrong and no listing that could leak a name. The base registry is asked first, so a tenant server cannot shadow a host tool by naming one of its own after it.

Public surface

  • vendo.tenantConnectors.register({ org, name, kind: "mcp" | "openapi", url?, spec?, token? }) — save-and-test in one call: it validates by actually connecting, so a registration that landed is a registration that worked, and the discovered tools are what you get back
  • .list(org) / .remove(org, name) / .test(org, name)
  • Types: TenantConnectorInput, TenantConnectorResult, TenantConnectorSummary, TenantConnectors
  • Boot summary gains a tenants row, only when a memberships seam exists (without one, no run can assert an org)
  • New doctor code + troubleshooting page production/troubleshooting/e-tenant-001
  • Docs: capabilities/tenant-connectors

Data handling

  • Tokens are vaulted in the store's encrypted secrets under a tenant-scoped name, read back only to build a connector. list and register answer descriptors and metadata, never the credential.
  • Erase cascade extended — an org id is a row subject, so erasing an org takes its registrations and its live token with it, leaving other tenants' tokens and host-owned secrets untouched.
  • Registrations live in the generic vendo_records collection: no store schema change, no migration.

One real code change on top of the rebase

S4 was built in parallel with S2, so it stubbed S2's connector — reading openApiConnector off the @vendoai/actions module object behind a locally-declared type, because a named import of a then-nonexistent export broke the Turbopack build. S2 is underneath now, so that indirection is gone for an ordinary named import. Before the swap the openapi registration path answered not-implemented; it now actually works.

How it was tested

13 seam tests, no mocks on either side: real node:http MCP server speaking real JSON-RPC, real REST fixture for the OpenAPI path, real PGlite with a real encryption key, and tool listings read off vendo.guardedTools — the same registry chat, the MCP door and automations execute through. The new OpenAPI counterpart registers against a live in-test server and asserts the request really landed there carrying the vaulted bearer token.

~690 blast-radius tests green.

🤖 Generated with Claude Code


Summary by cubic

Per-tenant connectors with structural isolation. Previously, connectors passed to createVendo({ connectors }) were global; now the host registers an org’s MCP server or OpenAPI spec at runtime and only that org’s users see and can call those tools.

  • Dev API on @vendoai/vendo: vendo.tenantConnectors.register|list|test|remove (types exported: TenantConnectorInput|Result|Summary|TenantConnectors). register save-tests by connecting and returns discovered tools; OpenAPI registrations now extract operations and execute.
  • Isolation is structural: a per-org overlay merges into the base; the base wins name conflicts, users in two orgs keep all tools, cache keys by org id (not combinations) to avoid “a,b” ambiguity, and a tenant cannot shadow a host tool.
  • Tokens are vaulted under tenant-scoped names from @vendoai/core and are never returned. Re-registering without a token clears the old one; tokenless registrations skip vault reads. Erasure cascades delete only the erased org’s connector tokens via the shared name builder; host-owned secrets are untouched.
  • Agent behavior: discovery and dispatch use the run’s context; find_tools searches the turn’s listing (not the shared registry) so tenant tools are discoverable and callable in chat. The boot summary shows a “tenants” row only when a memberships seam exists.
  • Doctor: adds E-TENANT-001 to warn when .tenantConnectors is wired without an encrypted vault; a Cloud key counts only when Cloud actually composes the store.
  • No schema changes: registrations live in generic records; no migration or redeploy required.

Rollout

  • Set VENDO_STORE_ENCRYPTION_KEY (base64, 32 bytes) or use VENDO_API_KEY when Vendo composes the store; if you pass your own createStore, configure encryption there (the env var will not apply). Tokenless registrations work without a vault.

Written for commit 9007cbb. Summary will update on new commits.

Review in cubic

@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown

Greptile Summary

This change adds tenant-scoped MCP and OpenAPI connector registration, encrypted credential storage, tenant-aware tool discovery, and connector lifecycle operations. Two failures remain in packages/vendo/src/tenant-connectors.ts: a failed replacement can send a new connector credential to the prior endpoint, and a connector removed through one running Vendo instance can remain available through another instance with a warmed overlay.

Confidence Score: 1/5

Do not merge until connector replacement updates are made atomic and cached connector overlays are invalidated across all instances using the store.

Two independently reproduced security-impacting failures remain: replacement credentials can be routed to an old endpoint after a write failure, and removed connectors can continue running from another instance's warmed cache.

Files Needing Attention: packages/vendo/src/tenant-connectors.ts

Security Review

Connector credentials and registration metadata can diverge when the record write fails after the vault is updated, causing the replacement bearer token to be sent to the old persisted endpoint. Connector revocation also does not propagate to other Vendo instances sharing the same store, so an already-cached connector can continue to be listed and executed after removal.

T-Rex T-Rex Logs

What T-Rex did

  • Reproduced the executable tenant connector replacement failure and captured control and failure logs.
  • Reproduced the shared-store tenant connector cache behavior, capturing pre-removal listing and post-durable removal stale listing.
  • Validated the general contract around tenant-connector put failures, using the authored reproduction script and examining the before/after logs.
  • Observed the cache warm/revocation flow and mapped the behavior to code paths in tenant-connectors.ts based on the registry cache artifacts.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (2)

  1. General comment

    P1 Failed connector replacement can disclose the replacement token to the old endpoint

    • Bug
      • register() validates a replacement against its new endpoint, updates or deletes the tenant vault secret, then writes the replacement row. If records().put() rejects, the method returns an error but the original row remains. test() and the tenant overlay read that original URL and the mutated secret, causing subsequent MCP handshakes to send the replacement bearer token to the old endpoint. The captured failure run shows api.test() selecting old and observing Bearer replacement-token.
    • Cause
      • The vault mutation at packages/vendo/src/tenant-connectors.ts:207-216 is not atomic with, and precedes, records().put() at :217-223; no compensation restores the prior secret when the row write fails. The consumer at :241-249 independently reads the persisted row and vault value.
    • Fix
      • Make the row and secret update atomic in a store-supported transaction, or preserve the prior secret and compensate on put failure. A durable design should ensure consumers never observe a new token with an old row: stage the credential under a versioned secret name and atomically switch the persisted row/version, then clean up only after the switch; on write failure delete the staged secret. Add a regression test injecting put failure after an existing tokened registration and asserting neither test() nor overlay sends the replacement token to the old URL.

    T-Rex Ran code and verified through T-Rex

  2. General comment

    P1 Tenant connector removal and replacement do not invalidate other Vendo instances' warmed overlays

    • Bug
      • With two createVendo / createTenantConnectors instances sharing one persistent store, instance A builds and caches Acme's connector registry. Instance B then removes Acme's connector. The shared store correctly returns no registration, but A continues to list mcp_billing_lookup_invoice and successfully dispatches it to the previously registered remote MCP server. Re-registering/replacing through B has the same invalidation gap and can leave A dispatching the old connector configuration and credential.
    • Cause
      • createTenantConnectors creates const cache = new Map... at packages/vendo/src/tenant-connectors.ts:183, making it private to one composition. register and remove delete only the mutating composition's cache entry at lines 224 and 238. registryFor at lines 257-269 returns A's existing cached promise without rereading the shared persistent records.
    • Fix
      • Use an invalidation mechanism shared by all compositions using the store (for example, an adapter-backed per-org revision/generation read by registryFor, with mutation atomically bumping that revision), or remove/shorten the cache and rebuild overlays from durable state. Add a regression test that creates two Vendo instances on one store, warms A, removes and replaces through B, then asserts A neither lists nor executes the removed connector and uses the replacement endpoint/token.

    T-Rex Ran code and verified through T-Rex

Reviews (6): Last reviewed commit: "docs(vendo): say whose store owns the te..." | Re-trigger Greptile

Comment thread packages/vendo/src/tenant-connectors.ts Outdated
Comment thread packages/vendo/src/tenant-connectors.ts Outdated
Comment thread packages/vendo/src/tenant-connectors.ts Outdated
Comment thread packages/vendo/src/tenant-connectors.ts Outdated
Comment thread packages/vendo/src/tenant-connectors.ts
@yousefh409
yousefh409 force-pushed the yousefh409/ingest-s4-tenants branch from 3681502 to a0ce61b Compare August 18, 2026 16:28
Comment thread packages/vendo/src/tenant-connectors.ts
Comment thread packages/vendo/src/tenant-connectors.ts
@yousefh409
yousefh409 force-pushed the yousefh409/ingest-s4-tenants branch from a0ce61b to 4340cfa Compare August 18, 2026 18:01
Comment on lines +213 to +217
const vaulted = secretName(input.org, input.name);
if (input.token === undefined) await deps.ops.secrets.delete(vaulted);
else await deps.ops.secrets.set(vaulted, input.token);
}
await records().put({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Replacement credential publication is non-atomic

register() writes the replacement secret before it persists the replacement registration row. An overlapping overlay build can therefore read the prior connector URL with the replacement token and send that bearer credential to the old endpoint. Publish the registration metadata and credential atomically, or prevent readers from observing the intermediate state.

Artifacts

Deterministic tenant connector overlap regression harness

  • Authored Vitest harness pauses the replacement registration record write after the secret is stored and starts an overlay build, takeaway: it deterministically opens the vulnerable interleaving.

Vitest output showing the old endpoint receiving the replacement bearer token

  • Observed successful Vitest run records the old persisted URL receiving `Bearer replacement-token` while the replacement record write is paused, takeaway: the credential-to-old-endpoint exposure is reproduced.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +183 to +269
const cache = new Map<string, Promise<ToolRegistry | undefined>>();

const api: TenantConnectors = {
async register(input) {
try {
const row: Registration = {
org: input.org,
name: input.name,
kind: input.kind,
...(input.url === undefined ? {} : { url: input.url }),
...(input.spec === undefined ? {} : { spec: input.spec }),
registeredAt: new Date().toISOString(),
...(input.token === undefined ? {} : { vaulted: true as const }),
};
// Validate by CONNECTING: the discovered tools are the proof, and they
// are what the caller gets back.
const tools = await connectorFor(row, input.token).descriptors();
if (input.token !== undefined && deps.ops === undefined) {
throw new VendoError(
"not-implemented",
"this deployment's store has no secret vault, so a tenant connector token cannot be stored: "
+ "use the default store (or any store on the named-operation surface — Vendo Cloud, your own Postgres via createStore).",
);
}
if (deps.ops !== undefined) {
// The vault always ends up holding exactly what this call VALIDATED
// with. A re-registration that pasted no token drops the old one
// instead of leaving runtime to send a credential to a url its owner
// never paired it with — which is also the only way `register`'s
// discovery and the later live calls can be the same request.
const vaulted = secretName(input.org, input.name);
if (input.token === undefined) await deps.ops.secrets.delete(vaulted);
else await deps.ops.secrets.set(vaulted, input.token);
}
await records().put({
id: rowId(input.org, input.name),
data: row as unknown as Json,
// The ownership stamp: an org id IS a row subject (§9.5), so the
// existing erase cascade's subject leg reaches these rows.
refs: { subject: input.org },
});
cache.delete(input.org);
return { status: "ok", tools };
} catch (error) {
return failed(error);
}
},

async list(org) {
return (await rowsFor(org)).map(summaryOf);
},

async remove(org, name) {
await records().delete(rowId(org, name));
if (deps.ops !== undefined) await deps.ops.secrets.delete(secretName(org, name));
cache.delete(org);
},

async test(org, name) {
try {
const row = (await records().get(rowId(org, name)))?.data as unknown as Registration | undefined;
if (row === undefined) {
throw new VendoError("not-found", `no tenant connector "${name}" registered for org "${org}"`);
}
return { status: "ok", tools: await connectorFor(row, await readToken(row)).descriptors() };
} catch (error) {
return failed(error);
}
},
};

const connectorsFor = async (org: string): Promise<Connector[]> =>
await Promise.all((await rowsFor(org)).map(async (row) => connectorFor(row, await readToken(row))));

const registryFor = (org: string): Promise<ToolRegistry | undefined> => {
let built = cache.get(org);
if (built === undefined) {
built = (async () => {
const connectors = await connectorsFor(org);
return connectors.length === 0 ? undefined : deps.bind(connectors);
})();
// A failed build is never cached: without this one transient store blip
// leaves a rejected promise here and every later turn rethrows it.
built.catch(() => cache.delete(org));
cache.set(org, built);
}
return built;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Connector cache is stale across instances

The overlay cache belongs to a single createTenantConnectors() composition, while mutations invalidate only that composition's entry. When another instance sharing the store removes or replaces a connector, this instance continues to list and execute its cached connector. Use a shared generation or invalidation mechanism, or revalidate cached registries against the store before use.

Artifacts

Deterministic tenant connector overlap regression harness

  • Authored Vitest harness pauses the replacement registration record write after the secret is stored and starts an overlay build, takeaway: it deterministically opens the vulnerable interleaving.

Vitest output showing the old endpoint receiving the replacement bearer token

  • Observed successful Vitest run records the old persisted URL receiving `Bearer replacement-token` while the replacement record write is paused, takeaway: the credential-to-old-endpoint exposure is reproduced.

Focused two-instance shared-store cache harness

  • Authored Vitest harness starts two live MCP endpoints and exercises cache population, cross-instance removal, re-registration, listing, and execution; the takeaway is that it directly covers the reported process-local cache path.

Observed stale overlay cache execution output

  • Captured `pnpm --filter @vendoai/vendo exec vitest run tests/tenant-connectors-process-local-cache.trex.test.ts` output exited 0 and records old endpoint calls after removal and replacement; the takeaway is that instance A serves stale connector state.

View artifacts

T-Rex Ran code and verified through T-Rex

yousefh409 and others added 7 commits August 18, 2026 20:11
openApiConnector({ spec, baseUrl, headers, name }) turns an OpenAPI
document into guarded tools. It is reuse, not new machinery: the same
extractor `vendo sync` runs over a spec file, and the same HTTP dispatch
a host tool executes through.

Two factorings made that sharing possible:

- extractOpenApi's document half moved to the pure src/openapi-document.ts
  (the binding-identity.ts precedent). sync/openapi.ts keeps node:fs and
  the spec-file entry points; the connector is handed the document in
  memory. Keeping it in sync/ would have dragged sync/common.ts and its
  TypeScript compiler into the runtime entry, which the portability gate
  forbids outright (FORBIDDEN_INPUTS: packages/actions/dist/sync/).
  Route naming and extractedRisk moved alongside it into
  binding-identity.ts, re-exported from sync/common.ts.
- registry.ts's HTTP leg — argument binding, path substitution, the tRPC
  envelope, the fetch — is runtime/http-dispatch.ts now, used by the
  registry and the connector both. The JSON accept/content-type envelope
  moved inside fetchHostTool, so neither caller sets it.

McpAuthContext and McpHeadersResolver stay working as deprecated aliases
of ConnectorAuthContext / ConnectorHeadersResolver. Both connectors are
re-exported from @vendoai/vendo/server (and so from vendoai/server), and
docs-site/capabilities/connectors.mdx documents them — mcpConnector's
first page.

The test stands up a live HTTP fixture, points the connector at a spec
describing it, and executes through createActions: no stub on either
side. It proves the round trip, that baseUrl beats servers[0], that a
headers resolver sees the principal and grant, and that risk tracks the
method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Attachments rode one message and ended with it. A non-image dropped in
chat now lands in that user's own `/user/files/` — private, and still
there next conversation — and the message carries only the reference, so
a spreadsheet is stored once instead of on every turn. Images are the
deliberate exception: they still ride inline, because that is how a model
sees a picture.

`POST /files` is the door (raw bytes under the file's own media type, 5 MiB
cap, no 413 rung), and `vendo.putUserFile` is the same server-side write
called from host code. Same name replaces — `/user` is last-write-wins —
so re-sending a corrected export needs no version suffix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drawer had a write door and no way in. Two read-only tools —
`vendo_user_files_list` and `vendo_user_files_read` — go on the ordinary
guard-bound registry, so a file dropped last month is found and read in
next month's conversation: the listing is the only thing that carries it
across, and nothing volatile goes into the cacheable prompt.

Both take a file NAME, never a path, and build the path themselves, so
there is no caller-supplied path to climb out of `/user/files` with. A
read comes back 200 lines at a time so a spreadsheet is walked rather
than truncated mid-row, and a file that is not text answers with its type
and size instead of mojibake.

The prompt teaches the two things the descriptors cannot: an app gets a
COPY of what it needs, and refreshing that copy when a newer file arrives
is the agent's job on that turn. There is no automatic sync anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t send

`POST /files` was exempt from the wire's json-mutation CSRF floor and paid
no toll for it, so with the ambient-cookie auth presets a hostile page
could push files into a signed-in user's drawer cross-origin. The comment
claimed the file's media type forced a preflight; it does not — our own
client posts `.txt` as `text/plain`, which is CORS-safelisted, so
`/apps/import`'s media-type allowlist would both admit the attack and
break real uploads.

The toll is now a required custom request header (`x-vendo-upload`): a
browser cannot set one cross-origin without winning a preflight, and this
wire answers none. It needs no secret and no per-session state.

Also: the 5 MiB cap is checked against the declared Content-Length before
the body is buffered, so an over-cap upload is no longer held in memory to
be measured. The post-read check stays the real bound for a chunked body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An outside agent connects through the MCP door AS the user, so it
inherited `vendo_user_files_list` and `vendo_user_files_read` along with
every other `vendo_*` tool. Files someone uploaded to talk to THIS product
about are not material an external agent gets to read just because it
authenticated as them.

The door's `withholdTools` is the mechanism that fits: it is checked
before the `vendo_` prefix bypass, so unlike the door menu it can actually
hold back a runtime tool, and it holds on every leg of the mount. The
in-product agent reads its own registry and is untouched.

The outside-agent pin goes green because the code changed, not because the
expectation moved; the new case asserts both directions so a change that
re-exposes these fails saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yousefh409
yousefh409 force-pushed the yousefh409/ingest-s3-userdata branch from 31bce71 to 1b4267c Compare August 18, 2026 20:22
yousefh409 and others added 6 commits August 18, 2026 20:22
…users get them

A customer with its own MCP server or OpenAPI spec had one way in: add the
connector to createVendo({ connectors }) and redeploy — after which every tenant
on the deployment has it, because there was only ever one tool registry.

vendo.tenantConnectors is the dev-side API that ends that. register takes an org,
an MCP URL or an OpenAPI spec, and the token the customer pasted; it validates by
ACTUALLY CONNECTING and answers with the tools the server really advertised, or a
typed error. list, test and remove are the rest of the admin screen. No Vendo
UI, no console step.

Visibility follows the orgs the host already asserts, and it is STRUCTURAL: a run
asserting acme is served the shared registry plus Acme's own, and a run asserting
globex is served a registry Acme's connector was never in. No filter over a
combined set, so no filter to get wrong.

No store schema change. Registrations ride the generic records collection stamped
with their org, so the existing erase cascade reaches them like any other
subject-ref'd row; the token is vaulted in the store's encrypted secrets under a
tenant-scoped name and no public surface reads it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…live, and erasure takes it

Two gaps behind the tenant-connector seam, closed.

vendo doctor gains E-TENANT-001. A host whose source reaches
vendo.tenantConnectors with neither VENDO_STORE_ENCRYPTION_KEY nor
VENDO_API_KEY set is deploying a feature that works on every laptop and fails on
the first credentialed registration in production, because the store keeps a
secret encrypted or not at all. Static like the rest of doctor: a source marker
and two env names, no store opened and no tenant server dialled — checking that
one tenant's server still answers is tenantConnectors.test's job, at runtime.

And the erase cascade now sweeps the tokens. vendo_secrets sat outside every
selector for a stated reason — name-keyed HOST config that no subject could
reach — and a tenant connector's vault name breaks that premise by carrying the
org that owns it, the same way vendo_effects stopped being unreachable when it
gained a subject. So an org-level erase takes its connector tokens with its
registrations, and NOTHING else: a deployment's own API_TOKEN belongs to the
deployment, not to any person. One name builder in @vendoai/core serves the
write side and the sweep, so the two can never drift.

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

S4 was built beside S2, so it read `openApiConnector` off the
`@vendoai/actions` module object behind a locally-declared type: a named
import of an export that did not exist yet failed the bundler. S2 has
landed underneath, so the indirection goes and the import is ordinary.

Before this, registering a `kind: "openapi"` tenant connector answered
`not-implemented`. It now extracts the spec's operations and executes
them, which the new seam tests prove against a live HTTP fixture: real
spec, real listener, real request, real vaulted bearer token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot shadow a host tool

Four defects in the tenant-connector overlay, and the first one reshapes the
rest.

One registry per ORG, never one per membership COMBINATION. Flattening every
asserted org's connectors into a single registry meant two tenants who both
called their connector "billing" composed the same tool name, and the registry
answers that with a `conflict` throw that is deliberately never cached. Because
the overlay awaits tenant descriptors on every listing, that throw took out the
WHOLE toolset — host tools included — for a person whose only mistake was
belonging to both. Kept apart, the collision cannot form; the merge is where the
two meet, and it de-duplicates instead of building. Keying the cache on a plain
org id rather than a joined list also removes the ambiguity that let one org
literally named "a,b" be served the tools of orgs a and b.

Execute now asks the BASE first, which is what its own comment always claimed
and the opposite of what it did. A tenant server naming a tool after a host tool
was the one that ran. The listing and the dispatch walk one order now, so the
tool a person is offered is the tool that runs.

And the vault ends up holding exactly what `register` VALIDATED with. A
re-registration that pasted no token kept the old secret, so discovery ran
against the new url unauthenticated while every later call shipped the previous
tenant credential to it. Tokenless now clears, and the two paths agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n chat

The registry was right and chat was blind, which is the worst shape a feature
can ship in: org A resolved its tenant's tools on every listing and its agent
still answered exactly like org B's.

Two things stood between them. The discovery hand searched `actions`, the
SHARED registry — and a registry has no caller, so it cannot see a per-request
overlay. The seam is gone; `find_tools` now scores THE TURN'S OWN LISTING, which
is the only set that is true for the caller, and is already the set THE LAW
projected and the agent menu curated. Then the call itself: `descriptorFor`
resolved a chosen tool with NO ctx, so a tenant tool was offered to the model,
picked by it, and came back "Unknown tool" from the very registry that had just
offered it. It reads with the run's ctx now, exactly like `list()` beside it and
for the same stated reason.

A tokenless registration no longer asks the vault. The read throws before it
looks for a row when the store has no key, so ONE credential-free connector took
down every turn for every member of that org; the row says whether it vaulted
anything, and a connector that needs no credential costs no read.

And doctor stops greening a deployment whose next registration is refused. A
Cloud key only proves a vault when Cloud is really the store — an explicitly
passed createStore() wins over VENDO_API_KEY — so the key now counts only where
nothing else claimed the seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page told a BYO host to set VENDO_STORE_ENCRYPTION_KEY "in production", and
both halves of that were wrong for them. The variable is read only where Vendo
composes the store, so a host passing its own createStore() never receives it —
and the refusal it warns about is not production-only, it fires in development
on such a store too. The advice was therefore a dead end: set the variable, and
a tokened registration is still refused.

Now it says whose vault it is. Vendo's store takes the environment variable; a
host-passed store carries its own encryption config, because an explicitly
passed store owns its secrets. Tokenless registrations are called out as
unaffected — they store no secret, so they need no vault at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yousefh409
yousefh409 force-pushed the yousefh409/ingest-s4-tenants branch from 7cd84e7 to 9007cbb Compare August 18, 2026 20:28
@yousefh409
yousefh409 changed the base branch from yousefh409/ingest-s3-userdata to main August 18, 2026 20:37
@yousefh409
yousefh409 added this pull request to the merge queue Aug 18, 2026
Comment on lines +214 to +223
if (input.token === undefined) await deps.ops.secrets.delete(vaulted);
else await deps.ops.secrets.set(vaulted, input.token);
}
await records().put({
id: rowId(input.org, input.name),
data: row as unknown as Json,
// The ownership stamp: an org id IS a row subject (§9.5), so the
// existing erase cascade's subject leg reaches these rows.
refs: { subject: input.org },
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Replacement credential publication is non-atomic

register() updates the vaulted token before persisting the replacement row. If records().put() fails, the method returns an error while the old row remains active with its prior URL. Subsequent test() and overlay construction read that old URL together with the replacement token, sending the new bearer credential to the old endpoint. Persist the row and credential atomically, or restore the prior secret when the row write fails.

Artifacts

Executable tenant connector replacement failure reproduction

  • Authored Node script that starts real local MCP endpoints, injects a record write failure after vault mutation, and asserts the Authorization header observed by the selected endpoint; it reproduces the old-URL/replacement-token mismatch.

Control log for successful tenant connector replacement

  • Executed control run with a successful record write; it shows the replacement endpoint receives the replacement bearer token, establishing the expected matched state.

Failure log showing replacement token sent to old tenant connector endpoint

  • Executed injected-failure run after an initial tokened registration; it shows the old persisted endpoint receives `Bearer replacement-token`, proving the mismatch.

View artifacts

T-Rex Ran code and verified through T-Rex

* the merge below is where the two meet, and it is a de-duplication, not a
* build. A plain org id is also unambiguous as a key, which a joined list of
* them would not be. */
const cache = new Map<string, Promise<ToolRegistry | undefined>>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Connector revocation leaves peer overlays active

The connector registry cache belongs to one createTenantConnectors() composition. register() and remove() only clear the cache of the instance performing the mutation, so another Vendo instance sharing the store can continue listing and executing a connector it cached before that connector was removed or replaced. Use a store-shared revision/invalidation mechanism, or revalidate cached overlays against durable connector state before use.

Artifacts

Executable shared-store tenant connector cache reproduction

  • Authored executable starts a live MCP server, composes two Vendo instances over one PGlite store, warms A, mutates via B, and asserts the observed stale listing and execution; the takeaway is that cross-instance cache invalidation is absent.

Connector listing and execution before cross-instance removal

  • Executed baseline command from `/home/user/repo` shows instance A lists the registered connector, durable state contains `billing`, and a real MCP `tools/call` succeeds; the takeaway is the initial state is live and correctly wired.

Stale connector listing and execution after durable removal by another instance

  • Executed mutation command from `/home/user/repo` shows B leaves `persistedRows: []`, but A still lists the connector and sends a real MCP `tools/call`; the takeaway is revocation is stale across instances.

View artifacts

T-Rex Ran code and verified through T-Rex

Merged via the queue into main with commit 6bc5cc8 Aug 18, 2026
25 checks passed
@yousefh409
yousefh409 deleted the yousefh409/ingest-s4-tenants branch August 18, 2026 20:48
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.

1 participant