From ddbc362ded0b27d9f4374bc7c84e795faf9fbb40 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:18:54 -0700 Subject: [PATCH 1/4] feat(sidecar,hub)!: thread the run's tenant and principal to step tools The hub's signed deploy frame already carries the run's tenantId and principalId (HarnessConfig). The sidecar now threads both through the child's substrate config as WORKFLOW_TENANT_ID / WORKFLOW_PRINCIPAL_ID and exposes them on the step env, so an agent tool package can address stock /api/tenants/:tenantId/* routes instead of a Workbench-specific mirror. The on-disk deployment record carries the pair too (version 2), so a boot-time restore reseeds it rather than standing up a child whose tools cannot name their tenant. The hub widens @intx/hub-api's existing run-bearer middleware from the single deploy endpoint to the whole tenant subtree, as an outer wrap of the built app. A request with no bearer credential is untouched. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/hub/src/index.ts | 10 +++- apps/hub/src/workflow-run-tenant-auth.ts | 51 +++++++++++++++++++ .../src/workflow-deployment-record.test.ts | 8 ++- .../sidecar/src/workflow-deployment-record.ts | 10 +++- .../sidecar/src/workflow-host-wiring/index.ts | 19 ++++++- .../src/workflow-substrate-factory/config.ts | 8 +++ .../src/workflow-substrate-factory/index.ts | 2 + .../step-env.test.ts | 4 ++ .../workflow-substrate-factory/step-env.ts | 19 +++++++ .../workflow-restore-defer-to-wake.test.ts | 8 ++- .../test/workflow-restore-quarantine.test.ts | 16 ++++-- .../workflow-restore-timeout-cancel.test.ts | 8 ++- 12 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 apps/hub/src/workflow-run-tenant-auth.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 41a6be3cce..70bbca6cd7 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -275,6 +275,7 @@ import { createInFlightRequestTracker, withInFlightRequestTracking, } from "./in-flight-requests"; +import { withWorkflowRunTenantAuth } from "./workflow-run-tenant-auth"; // Host policy constants, not configuration. const MAX_TARBALL_BYTES = 10 * 1024 * 1024; @@ -2350,8 +2351,15 @@ export async function createHub(config: HubConfig) { app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir))); // Stock Interchange currently leaves tenant creation and dispatch ungated. + // Let a workflow-run agent reach every stock tenant route with the run + // bearer, not just the deploy endpoint — see ./workflow-run-tenant-auth.ts + // for why this is an outer wrap. + const runBearerApp = withWorkflowRunTenantAuth(app, { + db, + authenticator: createWorkflowRunAuthenticator({ db }), + }); const inFlight = createInFlightRequestTracker(); - const servingApp = withInFlightRequestTracking(app, inFlight); + const servingApp = withInFlightRequestTracking(runBearerApp, inFlight); return { app: servingApp, diff --git a/apps/hub/src/workflow-run-tenant-auth.ts b/apps/hub/src/workflow-run-tenant-auth.ts new file mode 100644 index 0000000000..bb6a8c9ff1 --- /dev/null +++ b/apps/hub/src/workflow-run-tenant-auth.ts @@ -0,0 +1,51 @@ +// A workflow child has no browser session — only its sidecar bearer token +// and its own run address. `@intx/hub-api` already knows how to turn that +// pair into a `principal` + `tenant` context (`createWorkflowRunDeployAuth`), +// but mounts it on the single `/api/tenants/:tenantId/workflows/deployments` +// path, so every other stock tenant route 401s a run-authenticated caller. +// +// This composition root widens that mount to the whole tenant subtree, so an +// agent tool package can read and write stock tenant routes (principals, +// grants, credentials, providers, offerings, workflow deployments) with the +// run bearer instead of a Workbench-specific `/api/workflow-*` mirror. +// +// It has to be an outer wrap rather than an `app.use()` on the built app: +// `createApp()` registers `createResolveTenant` on `/api/tenants/:tenantId/*` +// before it returns, and Hono composes middleware in registration order — the +// same reasoning `./tenant-create-guard.ts` and `./dispatch-tenant-guard.ts` +// give for their own wraps. +// +// The middleware is a no-op for a request that presents no bearer credential +// (it calls `next()` with nothing set), so session traffic, the git-token +// smart-HTTP mounts, and the existing deploy mirror all behave exactly as +// before. The `:tenantId` path segment is never trusted: the middleware sets +// the tenant from the authenticated run's own scope, so a run cannot widen +// its reach by naming another tenant in the URL. +import { Hono } from "hono"; +import type { DB } from "@intx/db"; +import { + createWorkflowRunDeployAuth, + type AppEnv, + type WorkflowRunAuthenticator, +} from "@intx/hub-api"; + +export type WorkflowRunTenantAuthDeps = { + db: DB["db"]; + authenticator: WorkflowRunAuthenticator; +}; + +export function withWorkflowRunTenantAuth( + nativeApp: Hono, + deps: WorkflowRunTenantAuthDeps, +): Hono { + const wrapped = new Hono(); + wrapped.use( + "/api/tenants/:tenantId/*", + createWorkflowRunDeployAuth({ + db: deps.db, + authenticator: deps.authenticator, + }), + ); + wrapped.route("/", nativeApp); + return wrapped; +} diff --git a/apps/sidecar/src/workflow-deployment-record.test.ts b/apps/sidecar/src/workflow-deployment-record.test.ts index 4e0a769c12..28c7e9c702 100644 --- a/apps/sidecar/src/workflow-deployment-record.test.ts +++ b/apps/sidecar/src/workflow-deployment-record.test.ts @@ -27,7 +27,9 @@ async function makeDataDir(): Promise { } const baseRecord: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: "run_parked-test@example.com", definitionId: "def_1", sources: {}, @@ -98,7 +100,9 @@ describe("scanWorkflowDeploymentRecords reaps pre-cutover records", () => { test("an unreadable old-format record (missing approvedWireHash/sourceRef) is reaped once, then boots quietly", async () => { const dataDir = await makeDataDir(); const preCutover = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: "run_old-format@example.com", definitionId: "def_1", sources: {}, diff --git a/apps/sidecar/src/workflow-deployment-record.ts b/apps/sidecar/src/workflow-deployment-record.ts index f012b71a59..46369da3f7 100644 --- a/apps/sidecar/src/workflow-deployment-record.ts +++ b/apps/sidecar/src/workflow-deployment-record.ts @@ -39,9 +39,17 @@ const RECORD_FILENAME = "deployment.json"; * blindly. Validated at read time (the boot scan) at the trust boundary. */ export const WorkflowDeploymentRecord = type({ - version: "1", + version: "2", agentAddress: "string > 0", definitionId: "string > 0", + // The run's own tenant and principal, from the hub's signed deploy frame. + // Required since version 2: the child threads them into its substrate + // config so a step tool can address stock `/api/tenants/:tenantId/*` with + // the run bearer, and a restore that could not name them would stand up a + // child whose tools cannot reach the hub. A version-1 record fails this + // parse and is redeployed rather than restored. + tenantId: "string > 0", + principalId: "string > 0", sources: { "[string]": InferenceSource.array().atLeastLength(1), }, diff --git a/apps/sidecar/src/workflow-host-wiring/index.ts b/apps/sidecar/src/workflow-host-wiring/index.ts index 26fe82a749..55048aaeef 100644 --- a/apps/sidecar/src/workflow-host-wiring/index.ts +++ b/apps/sidecar/src/workflow-host-wiring/index.ts @@ -779,6 +779,15 @@ export function createSidecarDeployRouter(deps: { sources: NonNullable["sources"]; /** Correlates the child's inference events to the deploy's session. */ sessionId: string | undefined; + /** + * The run's own tenant and principal, from the hub's signed deploy frame + * (`HarnessConfig`). Threaded into the child's substrate config so a step + * tool can address stock `/api/tenants/:tenantId/*` with the run bearer. + * Persisted on the deployment record so a boot-time restore reseeds the + * same pair rather than losing it. + */ + tenantId: string; + principalId: string; /** * Hub public key recorded at the head for deploy-pack verification and * inbound hub-frame verification. Required for a single-step @@ -835,9 +844,11 @@ export function createSidecarDeployRouter(deps: { // rather than assign `undefined` to it -- hence one conditional-spread // per optional field, folded into this single literal. return { - version: 1 as const, + version: 2 as const, agentAddress: spec.agentAddress, definitionId: spec.definition.id, + tenantId: spec.tenantId, + principalId: spec.principalId, sources, ...(spec.sessionId !== undefined ? { sessionId: spec.sessionId } : {}), ...(spec.hubPublicKey !== undefined @@ -928,6 +939,8 @@ export function createSidecarDeployRouter(deps: { const substrateEnv: Record = { ...multistepSubstrateEnv, WORKFLOW_DEFINITION_ID: spec.definition.id, + WORKFLOW_TENANT_ID: spec.tenantId, + WORKFLOW_PRINCIPAL_ID: spec.principalId, WORKFLOW_RUN_REPO_ID: deploymentId, WORKFLOW_RUN_REF: "refs/heads/main", CLOSURE_PACKAGE_DIR: spec.closurePackageDir, @@ -1511,6 +1524,8 @@ export function createSidecarDeployRouter(deps: { definition, sources: projection.sources, sessionId: frame.config.sessionId, + tenantId: frame.config.tenantId, + principalId: frame.config.principalId, hubPublicKey: definition.stepOrder.length === 1 ? frame.hubPublicKey : undefined, approvedWireHash: projection.approvedWireHash, @@ -1809,6 +1824,8 @@ export function createSidecarDeployRouter(deps: { definition, sources: record.sources, sessionId: record.sessionId, + tenantId: record.tenantId, + principalId: record.principalId, hubPublicKey: record.hubPublicKey, approvedWireHash: record.approvedWireHash, closurePackageDir: applied.packageDir, diff --git a/apps/sidecar/src/workflow-substrate-factory/config.ts b/apps/sidecar/src/workflow-substrate-factory/config.ts index 67205046cf..ed85237f1c 100644 --- a/apps/sidecar/src/workflow-substrate-factory/config.ts +++ b/apps/sidecar/src/workflow-substrate-factory/config.ts @@ -26,6 +26,8 @@ import { InferenceSource } from "@intx/types/runtime"; export const SIDECAR_SUBSTRATE_CONFIG_KEYS = [ "SIDECAR_DATA_DIR", "WORKFLOW_DEFINITION_ID", + "WORKFLOW_TENANT_ID", + "WORKFLOW_PRINCIPAL_ID", "WORKFLOW_RUN_REPO_ID", "WORKFLOW_RUN_REF", "SIDECAR_SIGNING_PUBLIC_KEY", @@ -46,6 +48,12 @@ export const SubstrateConfig = type({ // capabilities route a step tool calls. Identity only: the definition // itself is evaluated from the closure, never read from a repo. WORKFLOW_DEFINITION_ID: "string > 0", + // The run's own tenant and principal, taken from the hub's signed deploy + // frame (`HarnessConfig.tenantId` / `.principalId`). A step tool addresses + // stock `/api/tenants/:tenantId/*` with them and the run bearer; without + // them a tool package has no way to name the tenant it is running for. + WORKFLOW_TENANT_ID: "string > 0", + WORKFLOW_PRINCIPAL_ID: "string > 0", WORKFLOW_RUN_REPO_ID: "string > 0", WORKFLOW_RUN_REF: "string > 0", SIDECAR_SIGNING_PUBLIC_KEY: "string > 0", diff --git a/apps/sidecar/src/workflow-substrate-factory/index.ts b/apps/sidecar/src/workflow-substrate-factory/index.ts index a7ada16bae..12a4a1410a 100644 --- a/apps/sidecar/src/workflow-substrate-factory/index.ts +++ b/apps/sidecar/src/workflow-substrate-factory/index.ts @@ -396,6 +396,8 @@ export function createSidecarSubstrateFactory( hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL), sidecarToken: validated.SIDECAR_TOKEN, definitionId: validated.WORKFLOW_DEFINITION_ID, + tenantId: validated.WORKFLOW_TENANT_ID, + principalId: validated.WORKFLOW_PRINCIPAL_ID, }; const buildStepEnv = createSidecarStepBuildEnv( durableConversation !== undefined diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts index 004941e994..5a34ab0bf5 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts @@ -56,6 +56,8 @@ function buildEnvDeps(dataDir: string) { sidecarToken: "sc-token", adapters: { resolve: () => undefined } as never, definitionId: "wfd_capability_owner", + tenantId: "ten_test", + principalId: "prin_test", }; } @@ -103,6 +105,8 @@ test("a body-step env with no staged deploy tree still carries definitionId, so const buildEnv = createSidecarStepBuildEnv({ ...buildEnvDeps(dataDir), definitionId: "wfd_parent_definition", + tenantId: "ten_test", + principalId: "prin_test", }); const sourcesRef: SourcesSnapshotRef = { current: { diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index 3bf345007f..09d3ac9913 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -151,6 +151,18 @@ export interface SidecarStepBuildEnvDeps { * side by this field). */ definitionId: string; + /** + * The run's own tenant and principal, threaded from the hub's signed + * deploy frame (`HarnessConfig.tenantId` / `.principalId`) through the + * `WORKFLOW_TENANT_ID` / `WORKFLOW_PRINCIPAL_ID` substrate-config + * entries. Carried on the step env beyond `BaseEnv` exactly like + * `definitionId` above so a tool package can address stock + * `/api/tenants/:tenantId/*` with the run bearer — the tenant is a path + * segment on every stock tenant route, and a step tool has no other + * sanctioned way to learn it. + */ + tenantId: string; + principalId: string; /** * Adapter registry the step agent resolves inference adapters through. * The child builds this eagerly at boot from the validated @@ -372,6 +384,8 @@ export function createSidecarStepBuildEnv( hubAccessUrl: string; sidecarToken: string; definitionId: string; + tenantId: string; + principalId: string; } = { // An `InferenceSource` names a `credentialId`, not an inline secret: // the reactor fills the request's credential at send time through @@ -447,6 +461,11 @@ export function createSidecarStepBuildEnv( hubAccessUrl: deps.hubArtifactsUrl, sidecarToken: deps.sidecarToken, definitionId: deps.definitionId, + // The run's own tenant/principal, for the tool bundles that address + // stock `/api/tenants/:tenantId/*` routes with the run bearer + // (`@corbits/access-tools`, `@corbits/connections-tools`). + tenantId: deps.tenantId, + principalId: deps.principalId, }; // Carry the materialized tool runtime to the tool-bearing // `agentFactory` via the env's symbol-keyed slot. The step-invoker diff --git a/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts b/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts index 92d6036f88..6fe56181f6 100644 --- a/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts +++ b/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts @@ -195,7 +195,9 @@ test("a single-step deployment's frozen dead source is never replayed at restore }); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", // A single-step deployment's `sources` table has exactly one entry, @@ -243,7 +245,9 @@ test("a multi-step deployment still restores eagerly from its frozen sources, un }); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_multi", sources: { diff --git a/apps/sidecar/test/workflow-restore-quarantine.test.ts b/apps/sidecar/test/workflow-restore-quarantine.test.ts index 176bf536c1..829f1c4146 100644 --- a/apps/sidecar/test/workflow-restore-quarantine.test.ts +++ b/apps/sidecar/test/workflow-restore-quarantine.test.ts @@ -178,7 +178,9 @@ test("a permanently unrestorable record is quarantined after RESTORE_QUARANTINE_ // no future boot ever makes `deriveDeploymentId` agree with the directory. const mismatchedDeploymentId = "dep_mismatched-directory"; const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: "run_permanent-case@example.com", definitionId: "def_1", sources: {}, @@ -239,7 +241,9 @@ test("a transiently unbuildable provider is retried every boot and never quarant const deploymentId = deriveDeploymentId(agentAddress); stageClosureDefinition(deploymentId); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", // `step-1`'s unbuildable source throws before `step-2` is ever @@ -297,7 +301,9 @@ test("a missing/incomplete closure staging directory quarantines as a permanent }); const brokenRecord: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: brokenAgentAddress, definitionId: "def_1", sources: {}, @@ -311,7 +317,9 @@ test("a missing/incomplete closure staging directory quarantines as a permanent ); const healthyRecord: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: healthyAgentAddress, definitionId: "def_1", // A buildable provider clears the source-admission gate and reaches diff --git a/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts b/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts index 201506251e..3e4fc4a1c5 100644 --- a/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts +++ b/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts @@ -101,7 +101,9 @@ test("a restore that outlasts its timeout corrects its record once the spawn act const agentAddress = "run_late-restore@example.com"; const deploymentId = deriveDeploymentId(agentAddress); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", sources: { @@ -177,7 +179,9 @@ test("a reclaiming teardown racing a dangling restore's boot-failure write is no const agentAddress = "run_teardown-race@example.com"; const deploymentId = deriveDeploymentId(agentAddress); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", sources: { From 7c6d4b4cdf150a79a62627876f0e461a6476e553 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:22:47 -0700 Subject: [PATCH 2/4] feat(access-tools)!: call stock principal and grant routes with the run bearer list_principals, list_grants, grant_access and revoke_access now speak the stock Interchange tenant routes with the workflow run's own bearer credential, instead of a Workbench-specific /api/workflow-access mirror. That mirror, its hub mount and its tests are deleted. The delegation ceiling the mirror enforced moves into the tool: before it grants or revokes, it reads the caller's own grants in the caller's own tenant and evaluates each requested resource/action pair with @intx/authz's own engine, refusing anything outside the caller's authority. Grants are not inherited across the tenant ancestor tree, so a parent tenant's grant is never read as authority in a child. Two fidelity gaps against a server-side ceiling remain, tracked as upstream asks on CL-7575: role-derived grants are invisible to the stock principal-filtered listing, and the read-then-write is not atomic. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/hub/package.json | 1 - apps/hub/src/index.ts | 19 - bun.lock | 28 +- packages/access-tools/package.json | 14 +- packages/access-tools/src/client.test.ts | 129 ++-- packages/access-tools/src/client.ts | 416 ++++++++---- packages/access-tools/src/index.ts | 2 + packages/access-tools/src/routes.ts | 591 ------------------ packages/access-tools/src/tool.test.ts | 20 +- packages/access-tools/src/tool.ts | 18 +- .../test/myra-orchestrates.test.ts | 443 ------------- packages/access-tools/test/routes.test.ts | 362 ----------- workflows/assistant/src/prompt.ts | 4 +- 13 files changed, 426 insertions(+), 1621 deletions(-) delete mode 100644 packages/access-tools/src/routes.ts delete mode 100644 packages/access-tools/test/myra-orchestrates.test.ts delete mode 100644 packages/access-tools/test/routes.test.ts diff --git a/apps/hub/package.json b/apps/hub/package.json index 2a6b1893d3..2f695e79e6 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@chat-adapter/state-memory": "^4.35.0", - "@corbits/access-tools": "workspace:*", "@corbits/agent-directory": "workspace:*", "@corbits/agent-lifecycle": "workspace:*", "@corbits/approvals": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 70bbca6cd7..099c432447 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -125,7 +125,6 @@ import { createPostgresBenchModelPolicyStore, createWorkflowCatalogRoutes, } from "@corbits/inference-catalog"; -import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; import { @@ -2297,24 +2296,6 @@ export async function createHub(config: HubConfig) { getPolicy: (tenantId) => benchModelPolicy.store.getPolicy(tenantId), }), ); - // Myra's own principal/grant surface (`@corbits/access-tools`' - // `list_principals`/`list_grants`/`grant_access`/`revoke_access`): the - // workflow-run-authenticated counterpart to the tenant-session - // `/api/tenants/:tenantId/principals` and `/grants` routes - // `@intx/hub-api` mounts above. Reuses the SAME `chatGrantStore`/ - // `chatConditionRegistry` every other extension's own requireGrant - // check runs against, so a seeded principal's real grants (see - // the deleted seeding package's `SEED_GRANTS`) gate this surface exactly like - // every other write route. - app.route( - "/api/workflow-access", - createWorkflowAccessRoutes({ - db, - authenticator: createWorkflowRunAuthenticator({ db }), - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - ); // Notify-to-reconnect for an OAuth-connected credential whose token // expired (Hugging Face today — see docs/onboarding-huggingface-connect.md): // a light periodic sweep over `@corbits/notify`'s pure diff --git a/bun.lock b/bun.lock index f6c0eaef04..bd9f9432cb 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,6 @@ "version": "0.0.1", "dependencies": { "@chat-adapter/state-memory": "^4.35.0", - "@corbits/access-tools": "workspace:*", "@corbits/agent-directory": "workspace:*", "@corbits/agent-lifecycle": "workspace:*", "@corbits/approvals": "workspace:*", @@ -201,18 +200,13 @@ }, "packages/access-tools": { "name": "@corbits/access-tools", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", "@intx/types": "workspace:*", "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", }, "devDependencies": { "@corbits/agent-directory": "workspace:*", @@ -3502,11 +3496,17 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3528,12 +3528,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -3582,8 +3582,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3630,8 +3628,6 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/packages/access-tools/package.json b/packages/access-tools/package.json index 7d858870d2..7da816805f 100644 --- a/packages/access-tools/package.json +++ b/packages/access-tools/package.json @@ -1,13 +1,12 @@ { "name": "@corbits/access-tools", "private": true, - "description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, plus the workflow-run-authenticated routes they call — a thin wrapper over the tenant's real principal/grant rows and Interchange's own grant-store authorization, never a reimplementation of authz", - "version": "0.0.3", + "description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, calling stock Interchange tenant routes with the workflow run's own bearer credential and enforcing a delegation ceiling before it grants", + "version": "0.0.4", "license": "LGPL-2.1-or-later", "type": "module", "exports": { - ".": "./src/index.ts", - "./routes": "./src/routes.ts" + ".": "./src/index.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -17,13 +16,8 @@ "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:" + "arktype": "catalog:" }, "devDependencies": { "@corbits/agent-directory": "workspace:*", diff --git a/packages/access-tools/src/client.test.ts b/packages/access-tools/src/client.test.ts index 8e2059a5c9..274cf989ff 100644 --- a/packages/access-tools/src/client.test.ts +++ b/packages/access-tools/src/client.test.ts @@ -3,6 +3,7 @@ import { expect, test } from "bun:test"; import { AccessForbiddenError, AccessNotFoundError, + DelegationCeilingError, grantAccess, listGrants, listPrincipals, @@ -10,9 +11,13 @@ import { type AccessToolClientConfig, } from "./client"; +const TENANT_BASE = "https://hub.example.com/api/tenants/ten_1"; + function testConfig(fetchImpl: typeof fetch): AccessToolClientConfig { return { - hubAccessUrl: "https://hub.example.com/api/workflow-access", + hubAccessUrl: "https://hub.example.com", + tenantId: "ten_1", + principalId: "prin_caller", sidecarToken: "sc-token", address: "run_1@workflow", fetchImpl, @@ -30,7 +35,7 @@ function nativePrincipal(id: string) { }; } -function nativeGrant(id: string) { +function nativeGrant(id: string, overrides: Record = {}) { return { id, principalId: "prin_2", @@ -40,12 +45,20 @@ function nativeGrant(id: string) { origin: "invoker", conditions: null, expiresAt: null, + roleId: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", + ...overrides, }; } -test("listPrincipals reads the native page envelope and follows nextCursor", async () => { +/** The caller's own ceiling: whatever `firstActionOutsideCeiling` reads back + * for `principalId=prin_caller`. */ +function ceilingPage(grants: readonly unknown[]) { + return Response.json({ data: grants, nextCursor: null }); +} + +test("listPrincipals reads the stock page envelope and follows nextCursor", async () => { const seen: string[] = []; const fetchImpl = (async (url: string | URL | Request) => { const raw = String(url); @@ -64,16 +77,12 @@ test("listPrincipals reads the native page envelope and follows nextCursor", asy const principals = await listPrincipals(testConfig(fetchImpl)); expect(principals.map((p) => p.id)).toEqual(["prin_1", "prin_2", "prin_3"]); - expect(principals[0]).toMatchObject({ - kind: "workflow", - refId: "prin_1@example.test", - status: "active", - }); + expect(seen[0]).toStartWith(`${TENANT_BASE}/principals?`); expect(seen).toHaveLength(2); expect(seen[1]).toContain("cursor=prin_2"); }); -test("listGrants passes filters through and reads the native page envelope", async () => { +test("listGrants passes filters through and reads the stock page envelope", async () => { let seenUrl = ""; const fetchImpl = (async (url: string | URL | Request) => { seenUrl = String(url); @@ -84,9 +93,9 @@ test("listGrants passes filters through and reads the native page envelope", asy principalId: "prin_2", resource: "workflow-run:*", }); - expect(seenUrl).toContain( - "/grants?principalId=prin_2&resource=workflow-run%3A*", - ); + expect(seenUrl).toContain(`${TENANT_BASE}/grants?`); + expect(seenUrl).toContain("principalId=prin_2"); + expect(seenUrl).toContain("resource=workflow-run%3A*"); expect(grants).toHaveLength(1); expect(grants[0]).toMatchObject({ id: "grant_1", @@ -115,23 +124,24 @@ test("listGrants follows nextCursor so a full page plus one all return", async ( principalId: "prin_2", }); expect(grants).toHaveLength(51); - expect(grants.map((g) => g.id)).toEqual(all.map((g) => g.id)); expect(seen).toHaveLength(2); - expect(seen[0]).toContain("principalId=prin_2"); expect(seen[1]).toContain("cursor=page_2"); }); -test("grantAccess posts one native single-action body per action and parses single GrantResponse objects", async () => { +test("grantAccess reads the caller's own grants first, then posts one stock single-action body per action", async () => { const posted: unknown[] = []; const fetchImpl = (async ( url: string | URL | Request, init?: RequestInit, ) => { - expect(String(url)).toBe( - "https://hub.example.com/api/workflow-access/grants", - ); - expect(init?.method).toBe("POST"); - const body = JSON.parse(String(init?.body)) as Record; + if (init?.method !== "POST") { + expect(String(url)).toContain("principalId=prin_caller"); + return ceilingPage([ + nativeGrant("own_1", { principalId: "prin_caller", action: "*" }), + ]); + } + expect(String(url)).toBe(`${TENANT_BASE}/grants`); + const body = JSON.parse(String(init.body)) as Record; posted.push(body); return new Response( JSON.stringify({ @@ -166,14 +176,33 @@ test("grantAccess posts one native single-action body per action and parses sing expect(grants.map((g) => g.action)).toEqual(["read", "write"]); }); -test("a 403 with the native error envelope surfaces as AccessForbiddenError with the hub's message", async () => { +test("grantAccess refuses a pair outside the caller's own authority, before any write", async () => { + const methods: (string | undefined)[] = []; + const fetchImpl = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { + methods.push(init?.method); + return ceilingPage([ + nativeGrant("own_1", { principalId: "prin_caller", action: "read" }), + ]); + }) as unknown as typeof fetch; + + await expect( + grantAccess(testConfig(fetchImpl), { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read", "write"], + }), + ).rejects.toThrow(DelegationCeilingError); + expect(methods).toEqual([undefined]); +}); + +test("a 403 from the stock route surfaces as AccessForbiddenError with the hub's message", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { - code: "forbidden", - message: "Not a member of this tenant", - }, + error: { code: "forbidden", message: "Not a member of this tenant" }, }), { status: 403 }, )) as unknown as typeof fetch; @@ -185,20 +214,13 @@ test("a 403 with the native error envelope surfaces as AccessForbiddenError with actions: ["read"], }), ).rejects.toThrow(AccessForbiddenError); - await expect( - grantAccess(testConfig(fetchImpl), { - principalId: "prin_2", - resource: "workflow-run:*", - actions: ["read"], - }), - ).rejects.toThrow("Not a member of this tenant"); }); -test("a 404 with the native error envelope from revokeAccess surfaces as AccessNotFoundError", async () => { +test("a 404 looking up the grant to revoke surfaces as AccessNotFoundError", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "not_found", message: "Grant not found" }, + error: { code: "not_found", message: "no such grant" }, }), { status: 404 }, )) as unknown as typeof fetch; @@ -208,11 +230,44 @@ test("a 404 with the native error envelope from revokeAccess surfaces as AccessN ).rejects.toThrow(AccessNotFoundError); }); -test("revokeAccess treats a native 204 with no body as success", async () => { - const fetchImpl = (async () => - new Response(null, { status: 204 })) as unknown as typeof fetch; +test("revokeAccess checks the ceiling against the grant it read back, then deletes", async () => { + const calls: string[] = []; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + calls.push(`${init?.method ?? "GET"} ${String(url)}`); + if (init?.method === "DELETE") return new Response(null, { status: 204 }); + if (String(url).includes("principalId=prin_caller")) { + return ceilingPage([ + nativeGrant("own_1", { principalId: "prin_caller", action: "*" }), + ]); + } + return Response.json(nativeGrant("grant_1")); + }) as unknown as typeof fetch; await revokeAccess(testConfig(fetchImpl), "grant_1"); + expect(calls[0]).toBe(`GET ${TENANT_BASE}/grants/grant_1`); + expect(calls.at(-1)).toBe(`DELETE ${TENANT_BASE}/grants/grant_1`); +}); + +test("revokeAccess refuses a grant outside the caller's own authority", async () => { + const methods: (string | undefined)[] = []; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + methods.push(init?.method); + if (String(url).includes("principalId=prin_caller")) { + return ceilingPage([]); + } + return Response.json(nativeGrant("grant_1")); + }) as unknown as typeof fetch; + + await expect(revokeAccess(testConfig(fetchImpl), "grant_1")).rejects.toThrow( + DelegationCeilingError, + ); + expect(methods).not.toContain("DELETE"); }); test("a grant body missing principalId is a shape error, never a null-linked grant", async () => { diff --git a/packages/access-tools/src/client.ts b/packages/access-tools/src/client.ts index db68b41cab..92ca1da483 100644 --- a/packages/access-tools/src/client.ts +++ b/packages/access-tools/src/client.ts @@ -1,22 +1,27 @@ -// Client for the workflow-run-authenticated counterpart of the native -// Interchange tenant routes (`@intx/hub-api`: `createPrincipalRoutes`, -// `createGrantRoutes`) — see `./routes.ts`, mounted at -// `/api/workflow-access`. Same auth-header shape, same error-handling, -// same arktype-response-parsing pattern as every other tool bundle's -// `client.ts` in this codebase (`@corbits/agent-directory-tools`, -// `@corbits/capability-tools`); only the base path differs. +// A minimal client for the STOCK `@intx/hub-api` principal and grant +// routes (`/api/tenants/:tenantId/principals`, `/grants`), spoken with the +// workflow run's own bearer credential: the sidecar token plus the run +// address, which the hub resolves to this run's principal and tenant before +// the stock handler's own `requireGrant` check runs. There is no +// Workbench-specific mirror of these routes any more. // -// Every request and response speaks the native Interchange contract: -// `{data, nextCursor}` pages for `GET /principals` and `GET /grants`, -// one single-action `POST /grants` body per grant (returning the single -// `GrantResponse` object), `DELETE /grants/:grantId` returning `204`, and -// the canonical `{error: {code, message}}` envelope on failures -// (`AccessForbiddenError` / `AccessNotFoundError` surface the hub's -// `message` verbatim so Myra can act on it). +// The tenant id is a path segment on every stock tenant route, so it rides +// in the client config; the sidecar threads it onto the step env from the +// hub's signed deploy frame. It is never trusted as authority — the hub sets +// the acting tenant from the authenticated run alone. +// +// Grants are NOT inherited across the tenant ancestor tree, so every read +// and write here is the run's own tenant and nothing above it. import { type } from "arktype"; +import { evaluateGrants, type GrantRule } from "@intx/authz"; export interface AccessToolClientConfig { + /** The hub's plain HTTP origin. */ readonly hubAccessUrl: string; + /** The run's own tenant — the `:tenantId` segment of every stock route. */ + readonly tenantId: string; + /** The run's own principal, whose authority bounds every grant it makes. */ + readonly principalId: string; readonly sidecarToken: string; readonly address: string; /** Override for tests; defaults to the global `fetch`. */ @@ -51,104 +56,161 @@ function authHeaders(config: AccessToolClientConfig): Record { }; } -/** Pulls `error.message` out of the canonical hub envelope - * (`{error: {code, message}}`, per `errorResponse` in - * `@intx/hub-common/errors`), if `body` matches that shape. */ +function tenantBase(config: AccessToolClientConfig): string { + return `${config.hubAccessUrl}/api/tenants/${encodeURIComponent(config.tenantId)}`; +} + +/** Pulls a message out of either error envelope the hub emits: the + * canonical `{error: {code, userMessage, refId}}` and the stock + * `{error: {code, message}}` shape the vendored routes use. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("message" in error)) { - return undefined; + if (error === null || typeof error !== "object") return undefined; + for (const key of ["userMessage", "message"] as const) { + if (key in error) { + const value = (error as Record)[key]; + if (typeof value === "string") return value; + } } - const message = (error as { message: unknown }).message; - return typeof message === "string" ? message : undefined; + return undefined; } -export class AccessForbiddenError extends Error { - override readonly name = "AccessForbiddenError"; +async function readErrorMessage( + response: Response, + fallback: string, +): Promise { + const body: unknown = await response.json().catch(() => undefined); + return errorMessageFrom(body) ?? fallback; } -export class AccessNotFoundError extends Error { - override readonly name = "AccessNotFoundError"; -} +/** Thrown when the hub rejects the request as forbidden — the caller's own + * principal holds no `principal:*`/`grant:*` grant — distinct from a bare + * transport/HTTP failure, so a caller can report honestly that a human must + * grant it access first. */ +export class AccessForbiddenError extends Error {} -async function throwForStatus( - operation: string, - response: Response, -): Promise { - const body: unknown = await response.json().catch(() => null); - const message = errorMessageFrom(body) ?? response.statusText; +/** Thrown for a not-found principal/grant id, distinct from a bare + * transport/HTTP failure. */ +export class AccessNotFoundError extends Error {} + +async function doRequest( + config: AccessToolClientConfig, + path: string, + init: RequestInit, + failureLabel: string, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch(`${tenantBase(config)}${path}`, { + ...init, + headers: { ...authHeaders(config), ...(init.headers ?? {}) }, + }); if (response.status === 403) { - throw new AccessForbiddenError(`${operation} failed: ${message}`); + throw new AccessForbiddenError( + await readErrorMessage(response, `${failureLabel}: forbidden`), + ); } if (response.status === 404) { - throw new AccessNotFoundError(`${operation} failed: ${message}`); + throw new AccessNotFoundError( + await readErrorMessage(response, `${failureLabel}: not found`), + ); } - throw new Error(`${operation} failed: ${message}`); + if (!response.ok) { + throw new Error( + `${failureLabel}: ${response.status} ${response.statusText}`, + ); + } + return response; } -const ListedPrincipalsResponse = type({ +const PAGE_LIMIT = 100; + +const PrincipalsPage = type({ data: type({ id: "string", - kind: "'user' | 'agent' | 'workflow'", + kind: "'user'|'agent'|'workflow'", refId: "string", - status: "'active' | 'suspended' | 'invited' | 'deactivated'", + status: "'active'|'suspended'|'invited'|'deactivated'", }).array(), "nextCursor?": "string | null", }); -const ListedGrantsResponse = type({ +const GrantsPage = type({ data: type({ id: "string", principalId: "string | null", resource: "string", action: "string", - effect: "'allow' | 'deny' | 'ask'", + effect: "'allow'|'deny'|'ask'", + origin: "'system'|'role'|'creator'|'invoker'", + "roleId?": "string | null", + "conditions?": "Record | null", + "expiresAt?": "string | null", }).array(), "nextCursor?": "string | null", }); -const CreatedGrantResponse = type({ +const CreatedGrant = type({ id: "string", principalId: "string | null", resource: "string", action: "string", - effect: "'allow' | 'deny' | 'ask'", + effect: "'allow'|'deny'|'ask'", }); -/** Lists every principal in the run's tenant, following the native - * `nextCursor` pages to the end — Myra needs the full list to find the - * agent she just created before granting it access. */ -export async function listPrincipals( +type GrantRow = (typeof GrantsPage.infer)["data"][number]; + +/** Walks every page of a stock paginated collection. The tool surfaces a + * tenant's whole principal or grant list, so a truncated first page would + * read as "that principal has no grants" — a wrong answer, not a slow one. */ +async function readAllPages( config: AccessToolClientConfig, -): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); - const principals: ListedPrincipal[] = []; - let cursor: string | null | undefined; - do { - const url = - cursor === undefined || cursor === null - ? `${config.hubAccessUrl}/principals` - : `${config.hubAccessUrl}/principals?cursor=${encodeURIComponent(cursor)}`; - const response = await fetchImpl(url, { - headers: { ...authHeaders(config) }, - }); - if (!response.ok) { - await throwForStatus("Listing principals", response); - } - const body: unknown = await response.json(); - const parsed = ListedPrincipalsResponse(body); + path: string, + query: URLSearchParams, + failureLabel: string, + parse: ( + body: unknown, + ) => + | { data: readonly T[]; nextCursor?: string | null | undefined } + | type.errors, +): Promise { + const items: T[] = []; + let cursor: string | undefined; + for (;;) { + const params = new URLSearchParams(query); + params.set("limit", String(PAGE_LIMIT)); + if (cursor !== undefined) params.set("cursor", cursor); + const response = await doRequest( + config, + `${path}?${params.toString()}`, + {}, + failureLabel, + ); + const parsed = parse(await response.json()); if (parsed instanceof type.errors) { throw new Error( - `Principals response did not match the expected shape: ${parsed.summary}`, + `${failureLabel}: response did not match the expected shape: ${parsed.summary}`, ); } - principals.push(...parsed.data); - cursor = parsed.nextCursor ?? null; - } while (cursor !== null); - return principals; + items.push(...parsed.data); + const next = parsed.nextCursor; + if (next === undefined || next === null) return items; + cursor = next; + } +} + +export async function listPrincipals( + config: AccessToolClientConfig, +): Promise { + return await readAllPages( + config, + "/principals", + new URLSearchParams(), + "Listing principals failed", + (body) => PrincipalsPage(body), + ); } export interface ListGrantsFilter { @@ -156,92 +218,182 @@ export interface ListGrantsFilter { readonly resource?: string; } -/** Lists every grant in the run's tenant matching the given filters, - * following the native `nextCursor` pages to the end — the mirror honors - * the same `principalId`/`resource` query params as the native - * `GET /grants`. */ +async function listGrantRows( + config: AccessToolClientConfig, + filter?: ListGrantsFilter, +): Promise { + const params = new URLSearchParams(); + if (filter?.principalId !== undefined) { + params.set("principalId", filter.principalId); + } + if (filter?.resource !== undefined) params.set("resource", filter.resource); + return await readAllPages( + config, + "/grants", + params, + "Listing grants failed", + (body) => GrantsPage(body), + ); +} + export async function listGrants( config: AccessToolClientConfig, filter?: ListGrantsFilter, -): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); - const grants: ListedGrant[] = []; - let cursor: string | null | undefined; - do { - const params = new URLSearchParams(); - if (filter?.principalId !== undefined) - params.set("principalId", filter.principalId); - if (filter?.resource !== undefined) params.set("resource", filter.resource); - if (cursor !== undefined && cursor !== null) params.set("cursor", cursor); - const query = params.size === 0 ? "" : `?${params.toString()}`; - const response = await fetchImpl(`${config.hubAccessUrl}/grants${query}`, { - headers: { ...authHeaders(config) }, +): Promise { + const rows = await listGrantRows(config, filter); + return rows.map((row) => ({ + id: row.id, + principalId: row.principalId, + resource: row.resource, + action: row.action, + effect: row.effect, + })); +} + +function toGrantRule(row: GrantRow): GrantRule { + return { + id: row.id, + resource: row.resource, + action: row.action, + effect: row.effect, + origin: row.origin, + conditions: row.conditions ?? null, + expiresAt: + row.expiresAt === undefined || row.expiresAt === null + ? null + : new Date(row.expiresAt), + roleId: row.roleId ?? null, + principalId: row.principalId, + }; +} + +/** Thrown when the caller asks to grant or revoke authority it does not + * itself hold. */ +export class DelegationCeilingError extends Error {} + +/** + * The delegation ceiling: a caller may only grant (or revoke) authority it + * already holds itself, or `grant:*`/`create` alone would let any principal + * escalate past its own reach. + * + * Stock `POST /grants` has no such check, so the tool enforces it before + * calling: it reads the caller's own grants in its own tenant and evaluates + * each requested pair with `@intx/authz`'s own `evaluateGrants`, the same + * engine the hub authorizes with. A parent tenant's grant is never consulted + * — grants are not inherited across the ancestor tree, unlike credentials + * and tool packages. + * + * Returns the first action outside the ceiling, or null when every pair is + * within it. + * + * Two fidelity gaps against a server-side check, both recorded as upstream + * asks on CL-7575: role-derived grants are not visible through the stock + * principal-filtered listing, and the read-then-write is not atomic. + */ +export async function firstActionOutsideCeiling( + config: AccessToolClientConfig, + resource: string, + actions: readonly string[], +): Promise { + const own = await listGrantRows(config, { + principalId: config.principalId, + }); + const rules = own.map(toGrantRule); + for (const action of actions) { + const result = await evaluateGrants(rules, resource, action, { + principalId: config.principalId, + tenantId: config.tenantId, }); - if (!response.ok) { - await throwForStatus("Listing grants", response); - } - const body: unknown = await response.json(); - const parsed = ListedGrantsResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `List-grants response did not match the expected shape: ${parsed.summary}`, - ); - } - grants.push(...parsed.data); - cursor = parsed.nextCursor ?? null; - } while (cursor !== null); - return grants; + if (result.effect !== "allow") return action; + } + return null; } -/** Grants access by posting one native single-action `POST /grants` body - * per requested action — `effect: "allow"`, `origin: "invoker"` — and - * parsing each single `GrantResponse` object the hub returns. */ export async function grantAccess( config: AccessToolClientConfig, - request: GrantAccessRequest, -): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); + input: GrantAccessRequest, +): Promise { + const outside = await firstActionOutsideCeiling( + config, + input.resource, + input.actions, + ); + if (outside !== null) { + throw new DelegationCeilingError( + `Cannot grant "${input.resource}" "${outside}": exceeds the caller's own authority`, + ); + } + + // Stock `POST /grants` creates one resource/action pair per call, so a + // multi-action request is one call per action. A failure part-way leaves + // the already-created grants in place and surfaces loudly rather than + // rolling back behind the caller's back. const created: ListedGrant[] = []; - for (const action of request.actions) { - const response = await fetchImpl(`${config.hubAccessUrl}/grants`, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify({ - principalId: request.principalId, - resource: request.resource, - action, - effect: "allow", - origin: "invoker", - }), - }); - if (!response.ok) { - await throwForStatus("Granting access", response); - } - const body: unknown = await response.json(); - const parsed = CreatedGrantResponse(body); + for (const action of input.actions) { + const response = await doRequest( + config, + "/grants", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + principalId: input.principalId, + resource: input.resource, + action, + effect: "allow", + // The invoking human's authority delegated this grant: the tool + // declares `approval: "ask"`, so a human approved this exact + // principal/resource/actions triple before this call ran. + origin: "invoker", + }), + }, + "Granting access failed", + ); + const parsed = CreatedGrant(await response.json()); if (parsed instanceof type.errors) { throw new Error( `Grant-access response did not match the expected shape: ${parsed.summary}`, ); } - created.push(parsed); + created.push({ + id: parsed.id, + principalId: parsed.principalId, + resource: parsed.resource, + action: parsed.action, + effect: parsed.effect, + }); } return created; } -/** Revokes the grant with the given id. The native - * `DELETE /grants/:grantId` returns `204` with no body, so success is the - * absence of a throw. */ export async function revokeAccess( config: AccessToolClientConfig, grantId: string, ): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); - const response = await fetchImpl( - `${config.hubAccessUrl}/grants/${encodeURIComponent(grantId)}`, - { method: "DELETE", headers: { ...authHeaders(config) } }, + const response = await doRequest( + config, + `/grants/${encodeURIComponent(grantId)}`, + {}, + "Revoking access failed", ); - if (!response.ok) { - await throwForStatus("Revoking access", response); + const target = CreatedGrant(await response.json()); + if (target instanceof type.errors) { + throw new Error( + `Grant lookup response did not match the expected shape: ${target.summary}`, + ); + } + const outside = await firstActionOutsideCeiling(config, target.resource, [ + target.action, + ]); + if (outside !== null) { + throw new DelegationCeilingError( + `Cannot revoke "${target.resource}" "${target.action}": exceeds the caller's own authority`, + ); } + await doRequest( + config, + `/grants/${encodeURIComponent(grantId)}`, + { method: "DELETE" }, + "Revoking access failed", + ); } diff --git a/packages/access-tools/src/index.ts b/packages/access-tools/src/index.ts index e818babd64..7c4ea44ae9 100644 --- a/packages/access-tools/src/index.ts +++ b/packages/access-tools/src/index.ts @@ -5,6 +5,8 @@ export { revokeAccess, AccessForbiddenError, AccessNotFoundError, + DelegationCeilingError, + firstActionOutsideCeiling, type AccessToolClientConfig, type GrantAccessRequest, type ListedGrant, diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts deleted file mode 100644 index 10a6a16d32..0000000000 --- a/packages/access-tools/src/routes.ts +++ /dev/null @@ -1,591 +0,0 @@ -// The workflow-run-authenticated counterpart of Interchange's native -// tenant principal/grant routes (`@intx/hub-api`'s `createPrincipalRoutes` -// / `createGrantRoutes`, served at `/api/tenants/:tenantId/principals` and -// `/grants`) — the server half of `@corbits/access-tools`' -// `list_principals`, `list_grants`, `grant_access`, and `revoke_access` -// tools. A workflow child has no browser session, only its sidecar bearer -// token and its own run address, so it authenticates through a -// `WorkflowRunAuthenticator` rather than the tenant-session pipeline — -// the same reasoning `@corbits/agent-directory`'s -// workflow-capability-routes and `@corbits/chat`'s -// workflow-participant-routes give for their own bearer-authenticated -// mirrors. Mounted OUTSIDE the tenant prefix for that reason, at -// `/api/workflow-access`. Every write is scoped to the authenticated -// run's own tenant/principal alone — identity never rides in a request -// body. -// -// Same contract as the native routes: `{data, nextCursor}` cursor pages -// for the two `GET` lists (same cursor algorithm, so cursors stay -// interchangeable), the native `CreateGrant` single-action body for -// `POST /grants` returning the single native `GrantResponse` object, -// `204` deletes, and the canonical `{error: {code, message}}` envelope on -// every failure. The ONLY deliberate deltas from native are the run-bearer -// auth above and the delegation ceiling below: a caller may only grant -// (or revoke) authority it already holds itself — otherwise -// `grant:*`/`create` alone would let any principal escalate past its own -// ceiling. That check reuses `@intx/authz`'s own `authorize` against the -// SAME grant store `requireGrant` checks against, per requested pair. -// -// Authorization for every route runs through `@intx/hub-api`'s own -// `createRequireGrant` against that same store and condition registry, -// resolved for the caller's real principal row (fetched once here, -// exactly like `@intx/hub-api`'s own `createWorkflowRunDeployAuth` -// resolves the deploy route's bearer mirror). A principal the tenant -// hasn't granted `principal:*`/`grant:*` gets a real 403 from the real -// grant store, not a bundle-local approximation. -import { and, desc, eq, lt, ne, or, sql } from "drizzle-orm"; -import { Hono } from "hono"; -import { type } from "arktype"; - -import { parseGrantRow, type DB } from "@intx/db"; -import { grant, principal, tenant } from "@intx/db/schema"; -import { generateId } from "@intx/hub-common"; -import { - createRequireGrant, - idResource, - resolveWorkflowPrincipalLabels, - type RequireGrant, - type TenantEnv, -} from "@intx/hub-api"; -import { authorize } from "@intx/authz"; -import { base64urlDecode, base64urlEncode, CreateGrant } from "@intx/types"; -import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; - -export type WorkflowAccessRunScope = { - readonly tenantId: string; - readonly principalId: string; -}; - -export type WorkflowRunAuthenticator = { - resolve( - token: string, - runAddress: string, - ): Promise; -}; - -export type CreateWorkflowAccessRoutesDeps = { - db: DB["db"]; - authenticator: WorkflowRunAuthenticator; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; -}; - -/** Cursor paging identical to the native tenant lists - * (`vendor/intx/hub-api/src/pagination.ts`, reimplemented here because - * that module is not part of `@intx/hub-api`'s published surface): - * keyset on `(createdAt, id)` descending, opaque base64url cursor, default - * limit 50, max 100. Cursors minted here decode on the native routes and - * vice versa. */ -const DEFAULT_LIMIT = 50; -const MAX_LIMIT = 100; - -const CursorData = type({ - t: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - id: "string", -}); - -function parsePageParams(query: { - cursor: string | undefined; - limit: string | undefined; -}): { limit: number; cursor: { t: string; id: string } | null } { - const raw = query.limit !== undefined ? Number(query.limit) : DEFAULT_LIMIT; - const limit = Number.isNaN(raw) - ? DEFAULT_LIMIT - : Math.min(Math.max(1, raw), MAX_LIMIT); - let cursor: { t: string; id: string } | null = null; - if (query.cursor !== undefined) { - try { - const data = CursorData( - JSON.parse(new TextDecoder().decode(base64urlDecode(query.cursor))), - ); - cursor = data instanceof type.errors ? null : data; - } catch { - // report-error-ignore: a malformed cursor from a caller is bad - // input, not an incident — it falls back to the first page like an - // absent cursor, exactly as the native tenant routes do. - cursor = null; - } - } - return { limit, cursor }; -} - -function encodeCursor(createdAt: Date, id: string): string { - return base64urlEncode( - new TextEncoder().encode( - JSON.stringify({ t: createdAt.toISOString(), id }), - ), - ); -} - -function pageResponse( - items: T[], - rows: { createdAt: Date; id: string }[], - limit: number, -): { data: T[]; nextCursor: string | null } { - const hasMore = items.length === limit; - const lastRow = hasMore ? rows[rows.length - 1] : undefined; - return { - data: items, - nextCursor: - lastRow !== undefined - ? encodeCursor(lastRow.createdAt, lastRow.id) - : null, - }; -} - -// A caller may only grant (or revoke) authority it already holds itself — -// otherwise `grant:*`/`create` alone would let any principal escalate past -// its own ceiling. Reuses `@intx/authz`'s own `authorize` against the same -// grant store `requireGrant` checks against, per requested pair. -async function pairOutsideCeiling( - deps: Pick< - CreateWorkflowAccessRoutesDeps, - "grantStore" | "conditionRegistry" - >, - callerPrincipalId: string, - tenantId: string, - resource: string, - action: string, -): Promise { - const result = await authorize( - deps.grantStore, - callerPrincipalId, - tenantId, - resource, - action, - deps.conditionRegistry, - ); - return result.effect !== "allow"; -} - -function nativeError(code: string, message: string) { - return { error: { code, message } }; -} - -type ResolvedNames = { - roleNames: Map; - principalNames: Map; -}; - -async function resolveGrantNames( - db: DB["db"], - grants: (typeof grant.$inferSelect)[], -): Promise { - const roleIds = [ - ...new Set( - grants.map((g) => g.roleId).filter((id): id is string => id !== null), - ), - ]; - const principalIds = [ - ...new Set( - grants - .map((g) => g.principalId) - .filter((id): id is string => id !== null), - ), - ]; - - const roleNames = new Map(); - if (roleIds.length > 0) { - const roles = await db.query.role.findMany({ - where: (r, { inArray }) => inArray(r.id, roleIds), - }); - for (const r of roles) { - roleNames.set(r.id, r.name); - } - } - - const principalNames = new Map(); - if (principalIds.length > 0) { - const principals = await db.query.principal.findMany({ - where: (p, { inArray }) => inArray(p.id, principalIds), - }); - const userRefIds = principals - .filter((p) => p.kind === "user") - .map((p) => p.refId); - const workflowRefIds = principals - .filter((p) => p.kind === "workflow") - .map((p) => p.refId); - - const refToName = new Map(); - if (userRefIds.length > 0) { - const users = await db.query.user.findMany({ - where: (u, { inArray }) => inArray(u.id, userRefIds), - }); - for (const u of users) { - refToName.set(u.id, u.name); - } - } - if (workflowRefIds.length > 0) { - const wfNames = await resolveWorkflowPrincipalLabels(db, workflowRefIds); - for (const [refId, name] of wfNames) { - refToName.set(refId, name); - } - } - for (const p of principals) { - const name = refToName.get(p.refId); - if (name) principalNames.set(p.id, name); - } - } - - return { roleNames, principalNames }; -} - -/** The native `GrantResponse` item shape for one grant row. */ -function formatGrant(row: typeof grant.$inferSelect, names?: ResolvedNames) { - const parsed = parseGrantRow(row); - const ts = (d: Date): string => d.toISOString(); - return { - id: parsed.id, - tenantId: parsed.tenantId, - roleId: parsed.roleId ?? null, - roleName: (parsed.roleId && names?.roleNames.get(parsed.roleId)) ?? null, - principalId: parsed.principalId ?? null, - principalName: - (parsed.principalId && names?.principalNames.get(parsed.principalId)) ?? - null, - resource: parsed.resource, - action: parsed.action, - effect: parsed.effect, - conditions: parsed.conditions, - origin: parsed.origin, - expiresAt: parsed.expiresAt ? ts(parsed.expiresAt) : null, - createdAt: ts(parsed.createdAt), - updatedAt: ts(parsed.updatedAt), - }; -} - -export function createWorkflowAccessRoutes( - deps: CreateWorkflowAccessRoutesDeps, -): Hono { - const app = new Hono(); - const requireGrant: RequireGrant = createRequireGrant({ - grantStore: deps.grantStore, - conditionRegistry: deps.conditionRegistry, - }); - - app.use("*", async (c, next) => { - const authHeader = c.req.header("authorization") ?? ""; - const token = authHeader.startsWith("Bearer ") - ? authHeader.slice("Bearer ".length) - : ""; - const address = c.req.header("x-workflow-run-address") ?? ""; - const scope = await deps.authenticator.resolve(token, address); - if (scope === null) { - return c.json( - nativeError( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), - 401, - ); - } - - // Resolve the real tenant + principal rows, exactly like - // `@intx/hub-api`'s own `createWorkflowRunDeployAuth` does for the - // deploy route's bearer mirror — `createRequireGrant`'s middleware - // reads `c.get("principal")`/`c.get("tenant")` as full rows, not a - // bare id pair. - const [tenantRow, principalRow] = await Promise.all([ - deps.db.query.tenant.findFirst({ where: eq(tenant.id, scope.tenantId) }), - deps.db.query.principal.findFirst({ - where: and( - eq(principal.id, scope.principalId), - eq(principal.tenantId, scope.tenantId), - ), - }), - ]); - if ( - tenantRow === undefined || - principalRow === undefined || - principalRow.status !== "active" - ) { - return c.json( - nativeError( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), - 401, - ); - } - c.set("tenant", tenantRow); - c.set("principal", principalRow); - await next(); - }); - - app.get("/principals", requireGrant("principal:*", "read"), async (c) => { - const tenantCtx = c.get("tenant"); - const kind = c.req.query("kind"); - const status = c.req.query("status"); - const { limit, cursor } = parsePageParams({ - cursor: c.req.query("cursor"), - limit: c.req.query("limit"), - }); - - const conditions = [eq(principal.tenantId, tenantCtx.id)]; - if (kind === "user" || kind === "agent" || kind === "workflow") { - conditions.push(eq(principal.kind, kind)); - } - if ( - status === "active" || - status === "suspended" || - status === "invited" || - status === "deactivated" - ) { - conditions.push(eq(principal.status, status)); - } else { - // Exclude deactivated principals by default, like the native list. - conditions.push(ne(principal.status, "deactivated")); - } - if (cursor) { - conditions.push( - or( - lt(principal.createdAt, new Date(cursor.t)), - and( - eq(principal.createdAt, new Date(cursor.t)), - lt(principal.id, cursor.id), - ), - ) ?? sql`false`, - ); - } - - const rows = await deps.db.query.principal.findMany({ - where: and(...conditions), - orderBy: [desc(principal.createdAt), desc(principal.id)], - limit, - }); - - const assignments = - rows.length > 0 - ? await deps.db.query.principalRole.findMany({ - where: (pr, { inArray }) => - inArray( - pr.principalId, - rows.map((p) => p.id), - ), - }) - : []; - const assignedRoleIds = [...new Set(assignments.map((a) => a.roleId))]; - const assignedRoles = - assignedRoleIds.length > 0 - ? await deps.db.query.role.findMany({ - where: (r, { inArray }) => inArray(r.id, assignedRoleIds), - }) - : []; - const roleMap = new Map(assignedRoles.map((r) => [r.id, r])); - const rolesByPrincipal = new Map(); - for (const a of assignments) { - const r = roleMap.get(a.roleId); - if (!r) continue; - const list = rolesByPrincipal.get(a.principalId) ?? []; - list.push({ id: r.id, name: r.name }); - rolesByPrincipal.set(a.principalId, list); - } - - const userRefIds = rows - .filter((p) => p.kind === "user") - .map((p) => p.refId); - const workflowRefIds = rows - .filter((p) => p.kind === "workflow") - .map((p) => p.refId); - const identities = new Map< - string, - { displayName: string; email?: string } - >(); - if (userRefIds.length > 0) { - const users = await deps.db.query.user.findMany({ - where: (u, { inArray }) => inArray(u.id, userRefIds), - }); - for (const u of users) { - identities.set(u.id, { displayName: u.name, email: u.email }); - } - } - if (workflowRefIds.length > 0) { - const wfNames = await resolveWorkflowPrincipalLabels( - deps.db, - workflowRefIds, - ); - for (const [refId, displayName] of wfNames) { - identities.set(refId, { displayName }); - } - } - - const items = rows.map((p) => { - const identity = identities.get(p.refId); - return { - id: p.id, - tenantId: p.tenantId, - kind: p.kind, - refId: p.refId, - displayName: identity?.displayName ?? p.refId, - ...(identity?.email ? { email: identity.email } : {}), - status: p.status, - roles: rolesByPrincipal.get(p.id) ?? [], - createdAt: p.createdAt.toISOString(), - updatedAt: p.updatedAt.toISOString(), - }; - }); - - return c.json(pageResponse(items, rows, limit)); - }); - - app.get("/grants", requireGrant("grant:*", "read"), async (c) => { - const tenantCtx = c.get("tenant"); - const principalId = c.req.query("principalId"); - const roleId = c.req.query("roleId"); - const resource = c.req.query("resource"); - const effect = c.req.query("effect"); - const { limit, cursor } = parsePageParams({ - cursor: c.req.query("cursor"), - limit: c.req.query("limit"), - }); - - const conditions = [eq(grant.tenantId, tenantCtx.id)]; - if (principalId) conditions.push(eq(grant.principalId, principalId)); - if (roleId) conditions.push(eq(grant.roleId, roleId)); - if (resource) conditions.push(eq(grant.resource, resource)); - if (effect === "allow" || effect === "deny" || effect === "ask") { - conditions.push(eq(grant.effect, effect)); - } - if (cursor) { - conditions.push( - or( - lt(grant.createdAt, new Date(cursor.t)), - and(eq(grant.createdAt, new Date(cursor.t)), lt(grant.id, cursor.id)), - ) ?? sql`false`, - ); - } - - const rows = await deps.db.query.grant.findMany({ - where: and(...conditions), - orderBy: [desc(grant.createdAt), desc(grant.id)], - limit, - }); - - const names = await resolveGrantNames(deps.db, rows); - return c.json( - pageResponse( - rows.map((g) => formatGrant(g, names)), - rows, - limit, - ), - ); - }); - - app.post("/grants", requireGrant("grant:*", "create"), async (c) => { - const tenantCtx = c.get("tenant"); - const callerPrincipal = c.get("principal"); - // The native `CreateGrant` body: exactly one target, one action. - const body = CreateGrant(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json(nativeError("bad_request", body.summary), 400); - } - - const outside = await pairOutsideCeiling( - deps, - callerPrincipal.id, - tenantCtx.id, - body.resource, - body.action, - ); - if (outside) { - return c.json( - nativeError( - "forbidden", - `Cannot grant "${body.resource}" "${body.action}": exceeds the caller's own authority`, - ), - 403, - ); - } - - if (body.principalId != null) { - const target = await deps.db.query.principal.findFirst({ - where: and( - eq(principal.id, body.principalId), - eq(principal.tenantId, tenantCtx.id), - ), - }); - if (target === undefined) { - return c.json( - nativeError( - "not_found", - `No principal "${body.principalId}" in this tenant`, - ), - 404, - ); - } - } - - const now = new Date(); - const [row] = await deps.db - .insert(grant) - .values({ - id: generateId("grant"), - tenantId: tenantCtx.id, - roleId: body.roleId ?? null, - principalId: body.principalId ?? null, - resource: body.resource, - action: body.action, - effect: body.effect, - conditions: body.conditions ?? null, - // The invoking human's authority delegated this grant: the - // tool declares `approval: "ask"`, so a human already - // approved this exact principal/resource/action triple - // before this route ever ran. - origin: body.origin, - expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, - createdAt: now, - updatedAt: now, - }) - .returning(); - if (row === undefined) { - return c.json(nativeError("not_found", "Grant not found"), 404); - } - - const names = await resolveGrantNames(deps.db, [row]); - return c.json(formatGrant(row, names), 201); - }); - - app.delete( - "/grants/:grantId", - requireGrant(idResource("grant", "grantId"), "manage"), - async (c) => { - const tenantCtx = c.get("tenant"); - const callerPrincipal = c.get("principal"); - const grantId = c.req.param("grantId"); - const target = await deps.db.query.grant.findFirst({ - where: and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id)), - }); - if (target === undefined) { - return c.json(nativeError("not_found", "Grant not found"), 404); - } - - const outside = await pairOutsideCeiling( - deps, - callerPrincipal.id, - tenantCtx.id, - target.resource, - target.action, - ); - if (outside) { - return c.json( - nativeError( - "forbidden", - `Cannot revoke "${target.resource}" "${target.action}": exceeds the caller's own authority`, - ), - 403, - ); - } - - const deleted = await deps.db - .delete(grant) - .where(and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id))) - .returning(); - if (deleted.length === 0) { - return c.json(nativeError("not_found", "Grant not found"), 404); - } - return c.body(null, 204); - }, - ); - - return app; -} diff --git a/packages/access-tools/src/tool.test.ts b/packages/access-tools/src/tool.test.ts index fee8d29bd8..d2e159b48b 100644 --- a/packages/access-tools/src/tool.test.ts +++ b/packages/access-tools/src/tool.test.ts @@ -10,7 +10,9 @@ import { function testEnv(): WorkflowAccessEnv { return { - hubAccessUrl: "https://hub.example.com/api/workflow-access", + hubAccessUrl: "https://hub.example.com", + tenantId: "ten_1", + principalId: "prin_caller", sidecarToken: "sc-token", address: "run_1@workflow", } as unknown as WorkflowAccessEnv; @@ -38,7 +40,7 @@ test("list_grants rejects invalid input before ever calling fetch", async () => } }); -test("a 403 with the native error envelope from grant_access surfaces as a clear tool error", async () => { +test("a 403 from the stock grant route surfaces as a clear tool error", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => new Response( @@ -63,7 +65,7 @@ test("a 403 with the native error envelope from grant_access surfaces as a clear ); expect(result.isError).toBe(true); expect(result.content).toBe( - "Granting access failed: Myra can only grant room:read — she herself holds no room:write here", + "Myra can only grant room:read — she herself holds no room:write here", ); } finally { globalThis.fetch = originalFetch; @@ -82,6 +84,7 @@ test("a successful grant_access round-trips into list_grants", async () => { origin: "invoker", conditions: null, expiresAt: null, + roleId: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", }; @@ -100,7 +103,16 @@ test("a successful grant_access round-trips into list_grants", async () => { ); } if (path.endsWith("/grants")) { - // Native `GET /grants` returns the `{data, nextCursor}` page envelope. + // The caller's own ceiling read: wide enough to delegate anything. + if (String(url).includes("principalId=prin_caller")) { + return new Response( + JSON.stringify({ + data: [{ ...nativeRow("own_1", "*"), principalId: "prin_caller" }], + nextCursor: null, + }), + ); + } + // Stock `GET /grants` returns the `{data, nextCursor}` page envelope. return new Response( JSON.stringify({ data: diff --git a/packages/access-tools/src/tool.ts b/packages/access-tools/src/tool.ts index ca84cb556a..12c6d9896c 100644 --- a/packages/access-tools/src/tool.ts +++ b/packages/access-tools/src/tool.ts @@ -11,9 +11,9 @@ // sidecar's per-step env builder, the same ground every other manager- // tools bundle's own env keys are threaded from. // -// See `./client.ts` for the workflow-run-authenticated routes this -// bundle's execution calls, and `./routes.ts` for their hub-side -// implementation. +// See `./client.ts` for the stock Interchange tenant routes this bundle's +// execution calls with the run bearer, and for the delegation ceiling it +// enforces before it grants anything. import { defineTool } from "@intx/agent"; import type { BaseEnv } from "@intx/agent"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; @@ -37,6 +37,8 @@ export interface WorkflowAccessEnv extends BaseEnv { readonly hubAccessUrl: string; readonly sidecarToken: string; readonly address: string; + readonly tenantId: string; + readonly principalId: string; } const ListGrantsInput = type({ @@ -68,6 +70,8 @@ function clientConfig(env: WorkflowAccessEnv): AccessToolClientConfig { hubAccessUrl: env.hubAccessUrl, sidecarToken: env.sidecarToken, address: env.address, + tenantId: env.tenantId, + principalId: env.principalId, }; } @@ -187,7 +191,13 @@ async function runRevokeAccess( */ export const accessTools = defineTool({ id: "@corbits/access-tools/access", - requires: ["hubAccessUrl", "sidecarToken", "address"], + requires: [ + "hubAccessUrl", + "sidecarToken", + "address", + "tenantId", + "principalId", + ], definitions: [ { name: LIST_PRINCIPALS_TOOL }, { name: LIST_GRANTS_TOOL }, diff --git a/packages/access-tools/test/myra-orchestrates.test.ts b/packages/access-tools/test/myra-orchestrates.test.ts deleted file mode 100644 index 406c930269..0000000000 --- a/packages/access-tools/test/myra-orchestrates.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -// Myra orchestrates the bench, end to end (CL-7465): the exact tool -// bundles the reactor invokes — `@corbits/agent-directory-tools`'s -// `create_agent` and `@corbits/access-tools`'s `grant_access` — drive the -// REAL mounted hub routes (`createWorkflowAgentCreateRoutes` and -// `createWorkflowAccessRoutes`) through a stubbed `fetch` dispatcher, with -// a scripted turn list standing in for model inference. Turn 1 creates a -// specialist agent through the native workflow-run create route (same -// materialization the tenant-session route uses); turn 2 mints that -// specialist a scoped grant through the native grant route, which lands a -// real row in Postgres; turn 3 reads it back through `list_grants`. -// -// The deployer seam stays stubbed (a real deploy needs the sidecar probe), -// mirroring `packages/agent-directory/test/workflow-create-routes.test.ts`'s -// own fakes — the route, its auth, its inventory checks, and its core -// input are all real. The grant leg is fully real: route + in-memory -// `@intx/authz` store + drizzle Postgres handle. -// -// DB-gated like `./routes.test.ts`: needs `DATABASE_URL` (see -// `scripts/e2e/db-gate`). -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq } from "drizzle-orm"; -import { Hono } from "hono"; -import type { ToolCall } from "@intx/types/runtime"; - -import { createDB, schema, type DB } from "@intx/db"; -import { createInMemoryGrantStore } from "@intx/authz"; -import { generateId } from "@intx/hub-common"; -import { - createWorkflowAgentCreateRoutes, - type CapabilityInventoryProvider, - type CreateWorkflowAgentCreateRoutesDeps, - type WorkflowCapabilityRunScope, - type WorkflowCapabilityRunAuthenticator, -} from "@corbits/agent-directory"; -import { - agentDirectoryTools, - CREATE_AGENT_TOOL, - type WorkflowAgentDirectoryEnv, -} from "@corbits/agent-directory-tools"; -import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -import { - createWorkflowAccessRoutes, - type WorkflowRunAuthenticator, -} from "../src/routes"; -import { - accessTools, - GRANT_ACCESS_TOOL, - LIST_GRANTS_TOOL, - type WorkflowAccessEnv, -} from "../src/tool"; - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -function dbConfigFromUrl(url: string) { - const parsed = new URL(url); - return { - host: parsed.hostname, - port: parsed.port === "" ? 5432 : Number(parsed.port), - user: decodeURIComponent(parsed.username), - password: decodeURIComponent(parsed.password), - database: parsed.pathname.replace(/^\//, ""), - }; -} - -// Mirrors `./routes.test.ts`'s own helper: a system-origin store row for -// the caller, the same shape `createRequireGrant` and the ceiling check -// read in production. -function storeGrant(principalId: string, resource: string, action: string) { - return { - id: generateId("grant"), - resource, - action, - effect: "allow" as const, - origin: "system" as const, - conditions: null, - expiresAt: null, - roleId: null, - principalId, - }; -} - -describeIfDb("Myra orchestrates the bench", () => { - let db: DB; - - const tenantId = generateId("tenant"); - const callerPrincipalId = generateId("principal"); - const specialistPrincipalId = generateId("principal"); - const SIDECAR_TOKEN = "sidecar-token-for-this-run"; - const RUN_ADDRESS = "myra-run@tenant.example.test"; - const SPECIALIST_ADDRESS = "release-notes-writer@tenant.example.test"; - - const HUB_ORIGIN = "https://hub.example.com"; - - beforeAll(async () => { - if (databaseUrl === undefined) return; - db = createDB(dbConfigFromUrl(databaseUrl)); - - await db.db.insert(schema.tenant).values({ - id: tenantId, - name: "Myra Orchestrates Test Tenant", - slug: `myra-orchestrates-${tenantId}`, - domain: `myra-orchestrates-${tenantId}.localhost`, - parentId: null, - config: null, - }); - await db.db.insert(schema.principal).values([ - { - id: callerPrincipalId, - tenantId, - kind: "workflow", - refId: RUN_ADDRESS, - status: "active", - }, - { - id: specialistPrincipalId, - tenantId, - kind: "agent", - refId: SPECIALIST_ADDRESS, - status: "active", - }, - ]); - }); - - afterAll(async () => { - if (databaseUrl === undefined) return; - await db.db.delete(schema.grant).where(eq(schema.grant.tenantId, tenantId)); - await db.db - .delete(schema.principal) - .where(eq(schema.principal.tenantId, tenantId)); - await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); - }); - - // The agent-create fakes below mirror - // `packages/agent-directory/test/workflow-create-routes.test.ts` — one - // deliberate difference: `currentVersion` is the string `"1"`, matching - // the real `workflow_definition.current_version` text column (CL-6480), - // which the tool client parses as `"string"`. - type FakeDefinitionRow = { - id: string; - tenantId: string; - name: string; - description: string | null; - status: string; - currentVersion: string; - assetId: string | null; - }; - - const createdRow: FakeDefinitionRow = { - id: "def_new", - tenantId, - name: "Release Notes Writer", - description: null, - status: "deployed", - currentVersion: "1", - assetId: "ast_new", - }; - - function fakeAgentDb(): DB["db"] { - return { - query: { - tenant: { - findFirst: async () => ({ - id: tenantId, - domain: `myra-orchestrates-${tenantId}.localhost`, - parentId: null, - }), - }, - asset: { - findFirst: async () => ({ - id: "ast_corbits_tools", - tenantId, - kind: "package-registry" as const, - name: CORBITS_TOOLS_REGISTRY, - }), - }, - workflowDefinition: { - findFirst: async () => createdRow, - findMany: async () => [createdRow], - }, - }, - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => - Promise.resolve([ - { - tenantId, - creatorPrincipalId: null, - name: createdRow.name, - displayName: createdRow.name, - }, - ]), - }), - }), - }), - insert: () => ({ - values: () => { - const chain: Record = { - onConflictDoNothing: () => chain, - returning: () => Promise.resolve([{ id: createdRow.id }]), - then: (onFulfilled: unknown) => - Promise.resolve([]).then(onFulfilled as never), - }; - return chain; - }, - }), - } as unknown as DB["db"]; - } - - function fakeAssetService( - onPopulate?: (files: Record) => void, - ): CreateWorkflowAgentCreateRoutesDeps["assetService"] { - return { - createAsset: () => - Promise.resolve({ id: "ast_new", tenantId, kind: "workflow" }), - populateAsset: (params: { - tree: { files: Record }; - }) => { - onPopulate?.(params.tree.files); - return Promise.resolve({ commitSha: "deadbeef" }); - }, - readAssetBlob: () => { - throw new Error("not used in this test"); - }, - listAssetBlobs: () => Promise.resolve(["corbits-memory-tools-1.4.0.tgz"]), - } as unknown as CreateWorkflowAgentCreateRoutesDeps["assetService"]; - } - - const fakeCapabilityInventory: CapabilityInventoryProvider = { - resolve: () => - Promise.resolve({ - toolPackages: [{ name: "@corbits/memory-tools" }], - skills: [{ name: "research" }], - models: [{ canonicalName: "anthropic/claude-sonnet" }], - }), - }; - - const fakeSkillIndex = { - resolve: ( - _tenantId: string, - _principalId: string, - names: readonly string[], - ) => - Promise.resolve( - names.map((name) => ({ name, description: `What ${name} does.` })), - ), - }; - - function recordingDeployer() { - const deploys: { - tenantId: string; - principalId: string; - assetId: string; - assetName: string; - commitSha: string; - entry: string; - }[] = []; - return { - deploys, - deploy: (input: { - tenantId: string; - principalId: string; - assetId: string; - assetName: string; - commitSha: string; - entry: string; - }) => { - deploys.push(input); - return Promise.resolve({ - deploymentId: "dep_1", - definitionAssetId: input.assetId, - status: "deployed" as const, - }); - }, - }; - } - - const agentAuthenticator: WorkflowCapabilityRunAuthenticator = { - resolve: (token, address) => - Promise.resolve( - token === SIDECAR_TOKEN && address === RUN_ADDRESS - ? ({ - tenantId, - principalId: callerPrincipalId, - runId: "run_myra", - } satisfies WorkflowCapabilityRunScope) - : null, - ), - }; - - const accessAuthenticator: WorkflowRunAuthenticator = { - async resolve(token, runAddress) { - if (token !== SIDECAR_TOKEN || runAddress !== RUN_ADDRESS) return null; - return { tenantId, principalId: callerPrincipalId }; - }, - }; - - // Stands in for the hub origin: every bundle client builds absolute - // URLs under the mount points above, so the full URL passes straight - // through with method, auth headers, and body untouched — exactly what - // the sidecar sends in production. - function hubFetch(hub: Hono) { - return (async (input: string | URL | Request, init?: RequestInit) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - if (!url.startsWith(HUB_ORIGIN)) { - throw new Error(`myra-orchestrates test: unexpected fetch to ${url}`); - } - return hub.request(url, init); - }) as unknown as typeof fetch; - } - - function callFor(name: string, args: Record): ToolCall { - return { id: `call_${name}`, name, arguments: args }; - } - - test("a Myra run creates a specialist agent, then grants it scoped access", async () => { - const deployer = recordingDeployer(); - let populatedFiles: Record | undefined; - const agentRoutesApp = createWorkflowAgentCreateRoutes({ - db: fakeAgentDb(), - assetService: fakeAssetService((files) => { - populatedFiles = files; - }), - skillIndex: fakeSkillIndex, - capabilityInventory: fakeCapabilityInventory, - authenticator: agentAuthenticator, - deployer, - }); - - const accessRoutesApp = createWorkflowAccessRoutes({ - db: db.db, - authenticator: accessAuthenticator, - grantStore: createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "create"), - storeGrant(callerPrincipalId, "grant:*", "read"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]), - conditionRegistry: {}, - }); - - const hub = new Hono(); - // Same mount points as the real composition root - // (`apps/hub/src/index.ts`): the bundles' clients build these exact - // absolute paths, so the stubbed fetch below hands the full URL - // straight to this parent — no prefix surgery, no drift. - hub.route("/api/workflow-agent-directory", agentRoutesApp); - hub.route("/api/workflow-access", accessRoutesApp); - - const directoryEnv = { - hubAgentDirectoryUrl: HUB_ORIGIN, - hubChatUrl: `${HUB_ORIGIN}/api/workflow-chat`, - sidecarToken: SIDECAR_TOKEN, - address: RUN_ADDRESS, - } as unknown as WorkflowAgentDirectoryEnv; - const accessEnv = { - hubAccessUrl: `${HUB_ORIGIN}/api/workflow-access`, - sidecarToken: SIDECAR_TOKEN, - address: RUN_ADDRESS, - } as unknown as WorkflowAccessEnv; - - const originalFetch = globalThis.fetch; - globalThis.fetch = hubFetch(hub); - try { - const directoryBundle = agentDirectoryTools(directoryEnv); - const accessBundle = accessTools(accessEnv); - const signal = new AbortController().signal; - - // Turn 1 (stubbed inference): stand up the specialist. `invite: - // false` keeps this leg on the definitions route — opening the - // specialist's own chat is `@corbits/chat`'s participant surface, - // covered by its own suites, not this flow. - const created = await directoryBundle.run( - callFor(CREATE_AGENT_TOOL, { - name: "Release Notes Writer", - systemPrompt: "You write crisp release notes from commit lists.", - toolPackagePins: ["@corbits/memory-tools"], - invite: false, - }), - signal, - ); - expect(created.isError).toBe(false); - expect(created.content).toContain("Release Notes Writer"); - expect(created.content).toContain("def_new"); - - // The native core path ran: the definition's source tree was - // materialized and the tenant-scoped deploy recorded. - expect(populatedFiles).toBeDefined(); - expect(deployer.deploys).toHaveLength(1); - expect(deployer.deploys[0]?.tenantId).toBe(tenantId); - expect(deployer.deploys[0]?.assetName).toBe("release-notes-writer"); - expect(deployer.deploys[0]?.commitSha).toBe("deadbeef"); - - // Turn 2 (stubbed inference): give the specialist room:read, - // inside Myra's own ceiling — the route persists a native row. - const granted = await accessBundle.run( - callFor(GRANT_ACCESS_TOOL, { - principalId: specialistPrincipalId, - resource: "room:*", - actions: ["read"], - }), - signal, - ); - expect(granted.isError).toBe(false); - expect(granted.content).toContain("1 grant created"); - - // Turn 3 (stubbed inference): read it back through the native - // list surface — proof the grant survived the round trip. - const listed = await accessBundle.run( - callFor(LIST_GRANTS_TOOL, { principalId: specialistPrincipalId }), - signal, - ); - expect(listed.isError).toBe(false); - expect(listed.content).toContain( - `allow room:*:read -> principal ${specialistPrincipalId}`, - ); - } finally { - globalThis.fetch = originalFetch; - } - - // And the row is really in Postgres, not just in the route's - // response envelope. - const rows = await db.db.query.grant.findMany({ - where: eq(schema.grant.tenantId, tenantId), - }); - expect( - rows.some( - (row) => - row.principalId === specialistPrincipalId && - row.resource === "room:*" && - row.action === "read" && - row.effect === "allow", - ), - ).toBe(true); - }); -}); diff --git a/packages/access-tools/test/routes.test.ts b/packages/access-tools/test/routes.test.ts deleted file mode 100644 index ae2198ca84..0000000000 --- a/packages/access-tools/test/routes.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -// DB-gated: skipped when DATABASE_URL is unreachable, matching this -// repo's convention for tests that talk to a real Postgres (see -// `vendor/intx/hub-api/src/middleware/workflow-run-deploy-auth.test.ts`). -// Runs against the caller's own database and deletes every row it wrote -// in `afterAll`. -// -// Exercises `createWorkflowAccessRoutes` end to end against a real DB and -// an in-memory `@intx/authz` grant store — same authorization primitive -// `createRequireGrant` and the delegation-ceiling check both call — so -// this proves the ceiling is enforced against the SAME grants a real -// deploy would see, not a bundle-local approximation. -// -// Every assertion speaks the native Interchange contract (`@intx/hub-api`'s -// `createGrantRoutes`/`createPrincipalRoutes` vocabulary): `{data, -// nextCursor}` pages, single-action `POST /grants` bodies returning the -// single `GrantResponse` object, `204` deletes, and `{error: {code, -// message}}` envelopes. The workflow-access surface is the -// workflow-run-authenticated counterpart to `/api/tenants/:tenantId -// /principals` and `/grants` — same shapes, run bearer instead of a human -// session (see the mount comment in `apps/hub/src/index.ts`). -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq } from "drizzle-orm"; - -import { createDB, schema, type DB } from "@intx/db"; -import { createInMemoryGrantStore, type GrantStore } from "@intx/authz"; -import { generateId } from "@intx/hub-common"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -import { - createWorkflowAccessRoutes, - type WorkflowRunAuthenticator, -} from "../src/routes"; - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -function storeGrant( - principalId: string, - resource: string, - action: string, -): { - id: string; - resource: string; - action: string; - effect: "allow"; - origin: "system"; - conditions: null; - expiresAt: null; - roleId: null; - principalId: string; -} { - return { - id: generateId("grant"), - resource, - action, - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId, - }; -} - -describeIfDb("createWorkflowAccessRoutes", () => { - let db: DB; - - const tenantId = generateId("tenant"); - const callerPrincipalId = generateId("principal"); - const targetPrincipalId = generateId("principal"); - const SIDECAR_TOKEN = "sidecar-token-for-this-run"; - const RUN_ADDRESS = "workflow-run@tenant.example.test"; - - beforeAll(async () => { - if (databaseUrl === undefined) return; - db = createDB(dbConfigFromUrl(databaseUrl)); - - await db.db.insert(schema.tenant).values({ - id: tenantId, - name: "Access Tools Test Tenant", - slug: `access-tools-${tenantId}`, - domain: `access-tools-${tenantId}.localhost`, - parentId: null, - config: null, - }); - - await db.db.insert(schema.principal).values([ - { - id: callerPrincipalId, - tenantId, - kind: "workflow", - refId: RUN_ADDRESS, - status: "active", - }, - { - id: targetPrincipalId, - tenantId, - kind: "workflow", - refId: "workflow-run-target@tenant.example.test", - status: "active", - }, - ]); - }); - - afterAll(async () => { - if (databaseUrl === undefined) return; - await db.db.delete(schema.grant).where(eq(schema.grant.tenantId, tenantId)); - await db.db - .delete(schema.principal) - .where(eq(schema.principal.tenantId, tenantId)); - await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); - }); - - const authenticator: WorkflowRunAuthenticator = { - async resolve(token, runAddress) { - if (token !== SIDECAR_TOKEN || runAddress !== RUN_ADDRESS) return null; - return { tenantId, principalId: callerPrincipalId }; - }, - }; - - function mountedApp(grantStore: GrantStore) { - return createWorkflowAccessRoutes({ - db: db.db, - authenticator, - grantStore, - conditionRegistry: {}, - }); - } - - function request( - path: string, - init?: { method?: string; body?: unknown }, - ): Request { - return new Request(`http://hub.test${path}`, { - method: init?.method ?? "GET", - headers: { - authorization: `Bearer ${SIDECAR_TOKEN}`, - "x-workflow-run-address": RUN_ADDRESS, - "content-type": "application/json", - }, - ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}), - }); - } - - test("401 without a recognized sidecar bearer token / run address", async () => { - const app = mountedApp(createInMemoryGrantStore([])); - const req = new Request("http://hub.test/principals", { - headers: { authorization: "Bearer not-the-right-token" }, - }); - const res = await app.request(req); - expect(res.status).toBe(401); - }); - - test("GET /principals returns the native page envelope", async () => { - const app = mountedApp( - createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "principal:*", "read"), - ]), - ); - const res = await app.request(request("/principals")); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { id: string; kind: string; status: string }[]; - nextCursor: string | null; - }; - expect(Array.isArray(body.data)).toBe(true); - expect( - body.data.some( - (p) => p.id === callerPrincipalId && p.kind === "workflow", - ), - ).toBe(true); - expect(body.nextCursor).toBeNull(); - }); - - test("403 when the caller lacks grant:*/create", async () => { - const app = mountedApp(createInMemoryGrantStore([])); - const res = await app.request( - request("/grants", { - method: "POST", - body: { - principalId: targetPrincipalId, - resource: "room:*", - action: "read", - effect: "allow", - origin: "invoker", - }, - }), - ); - expect(res.status).toBe(403); - }); - - test("403 when the requested pair exceeds the caller's own ceiling", async () => { - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "create"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request("/grants", { - method: "POST", - body: { - principalId: targetPrincipalId, - resource: "room:*", - action: "write", - effect: "allow", - origin: "invoker", - }, - }), - ); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { message: string } }; - expect(body.error.message).toContain("write"); - }); - - test("201 within the caller's own ceiling returns the single native GrantResponse", async () => { - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "create"), - storeGrant(callerPrincipalId, "grant:*", "read"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request("/grants", { - method: "POST", - body: { - principalId: targetPrincipalId, - resource: "room:*", - action: "read", - effect: "allow", - origin: "invoker", - }, - }), - ); - expect(res.status).toBe(201); - const body = (await res.json()) as { - id: string; - principalId: string; - resource: string; - action: string; - effect: string; - origin: string; - }; - expect(body.principalId).toBe(targetPrincipalId); - expect(body.resource).toBe("room:*"); - expect(body.action).toBe("read"); - expect(body.effect).toBe("allow"); - - const listRes = await app.request( - request(`/grants?principalId=${targetPrincipalId}`), - ); - expect(listRes.status).toBe(200); - const listBody = (await listRes.json()) as { - data: { id: string }[]; - nextCursor: string | null; - }; - expect(listBody.data.some((g) => g.id === body.id)).toBe(true); - expect(listBody.nextCursor).toBeNull(); - - await db.db.delete(schema.grant).where(eq(schema.grant.id, body.id)); - }); - - test("revoke: 204 within the caller's own ceiling", async () => { - const inserted = await db.db - .insert(schema.grant) - .values({ - id: generateId("grant"), - tenantId, - roleId: null, - principalId: targetPrincipalId, - resource: "room:*", - action: "read", - effect: "allow", - conditions: null, - origin: "invoker", - expiresAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); - const grantableRow = inserted[0]; - if (grantableRow === undefined) - throw new Error("expected an inserted grant"); - - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "manage"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request(`/grants/${grantableRow.id}`, { method: "DELETE" }), - ); - expect(res.status).toBe(204); - - const gone = await db.db.query.grant.findFirst({ - where: eq(schema.grant.id, grantableRow.id), - }); - expect(gone).toBeUndefined(); - }); - - test("revoke: 403 outside the caller's own ceiling", async () => { - const inserted = await db.db - .insert(schema.grant) - .values({ - id: generateId("grant"), - tenantId, - roleId: null, - principalId: targetPrincipalId, - resource: "asset:*", - action: "write", - effect: "allow", - conditions: null, - origin: "creator", - expiresAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); - const ungrantableRow = inserted[0]; - if (ungrantableRow === undefined) - throw new Error("expected an inserted grant"); - - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "manage"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request(`/grants/${ungrantableRow.id}`, { method: "DELETE" }), - ); - expect(res.status).toBe(403); - - const stillThere = await db.db.query.grant.findFirst({ - where: eq(schema.grant.id, ungrantableRow.id), - }); - expect(stillThere).toBeDefined(); - - await db.db - .delete(schema.grant) - .where(eq(schema.grant.id, ungrantableRow.id)); - }); - - test("404 for an unknown grant id", async () => { - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "manage"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request(`/grants/${generateId("grant")}`, { method: "DELETE" }), - ); - expect(res.status).toBe(404); - }); -}); diff --git a/workflows/assistant/src/prompt.ts b/workflows/assistant/src/prompt.ts index ebfa1d2cd7..cf82b15ea4 100644 --- a/workflows/assistant/src/prompt.ts +++ b/workflows/assistant/src/prompt.ts @@ -33,14 +33,14 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory-tools", version: "0.0.4" }, { name: "@corbits/capability-tools", version: "0.0.6" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, - { name: "@corbits/connections-tools", version: "0.0.9" }, + { name: "@corbits/connections-tools", version: "0.0.10" }, { name: "@corbits/catalog-tools", version: "0.0.6" }, { name: "@corbits/skills-tools", version: "0.0.7" }, { name: "@corbits/mcp-tools", version: "0.0.11" }, { name: "@corbits/interaction-tools", version: "0.0.8" }, { name: "@corbits/manus-tools", version: "0.0.11" }, { name: "@corbits/workflow-authoring-tools", version: "0.0.6" }, - { name: "@corbits/access-tools", version: "0.0.3" }, + { name: "@corbits/access-tools", version: "0.0.4" }, ]; /** From 35ce946e947c1e36184d422173c0a96737540fce Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:29:58 -0700 Subject: [PATCH 3/4] feat(connections-tools)!: read live connections from stock tenant routes list_connections now answers from the stock Interchange provider and credential routes, read with the workflow run's own bearer credential: a connector is connected when the tenant holds an active credential against a provider named for it, the same keying an agent launch resolves a credential by. The tool pairs that answer with the deploying build's own connector registry for display names and links, which the hub does not know. The /api/workflow-connections/connections route is deleted along with its tests. The mount survives only as the MCP-server registry @corbits/mcp-tools resolves an mcp: server through; MCP servers are not a stock Interchange concept, so CL-8164 retires it with the rest of MCP server configuration. Until then list_connections no longer reports MCP-connected servers: a curated preset is still offered as a card, and counts as connected when a credential exists under its own id. Known gap, recorded upstream: the stock provider listing includes ancestor tenants but the stock credential listing does not, so a credential held only by a parent tenant reads as not connected. Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- apps/hub/src/index.ts | 17 -- packages/access-tools/tsconfig.json | 8 +- packages/access-tools/tsconfig.src.json | 6 - packages/connections-tools/package.json | 2 +- packages/connections-tools/src/client.test.ts | 120 ++++++++---- packages/connections-tools/src/client.ts | 185 ++++++++++-------- packages/connections-tools/src/index.ts | 6 +- packages/connections-tools/src/tool.test.ts | 113 +++-------- packages/connections-tools/src/tool.ts | 103 +++------- packages/connections/src/index.ts | 1 - .../src/workflow-connection-routes.test.ts | 140 +++---------- .../src/workflow-connection-routes.ts | 98 ++-------- .../test/manager-tools-scenario.test.ts | 27 +-- 13 files changed, 306 insertions(+), 520 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 099c432447..97bb1d903b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -17,7 +17,6 @@ import { createWorkflowRunDispatchStore, listAssetsForTenant, listVisibleOfferings, - resolveCredentialRequirement, } from "@intx/db"; import { asset as assetTable, @@ -677,18 +676,6 @@ export async function createHub(config: HubConfig) { }, }; const hubPublicKey = hexEncode(signingKey.publicKey); - // Same owning check GET /connections uses — see - // `@corbits/connections`' `workflow-connection-routes.ts` and the - // `createWorkflowConnectionRoutes` wiring below. Not - // `listConnectedProviders` (catalog-only). - const isConnectorConnected = async (tenantId: string, connectorId: string) => - (await resolveCredentialRequirement( - db, - tenantId, - { providerName: connectorId, source: "tenant" }, - null, - null, - )) !== null; // One resolver serves both seams, exactly as @intx/hub-sessions's own // reference host wires them: `resolve` turns a presented bearer token // into a verified identity at the handshake, and `isCurrent` @@ -2269,10 +2256,6 @@ export async function createHub(config: HubConfig) { "/api/workflow-connections", createWorkflowConnectionRoutes({ authenticator: createWorkflowRunAuthenticator({ db }), - registry: CONNECTOR_REGISTRY, - // Same `isConnectorConnected` the pinned-package factory is wired - // with above (CL-6492). - isConnectorConnected, listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId), }), ); diff --git a/packages/access-tools/tsconfig.json b/packages/access-tools/tsconfig.json index 25dfb52b86..8d56256d5d 100644 --- a/packages/access-tools/tsconfig.json +++ b/packages/access-tools/tsconfig.json @@ -10,18 +10,12 @@ "tsBuildInfoFile": "tsconfig.tsbuildinfo", "rootDir": "../.." }, - "include": ["src", "test"], + "include": ["src"], "exclude": [], "references": [ { "path": "../../vendor/intx/agent/tsconfig.src.json" }, - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, { "path": "../../vendor/intx/types/tsconfig.src.json" }, diff --git a/packages/access-tools/tsconfig.src.json b/packages/access-tools/tsconfig.src.json index 5debd4afb4..fba5f774c3 100644 --- a/packages/access-tools/tsconfig.src.json +++ b/packages/access-tools/tsconfig.src.json @@ -14,12 +14,6 @@ { "path": "../../vendor/intx/agent/tsconfig.src.json" }, - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, { "path": "../../vendor/intx/types/tsconfig.src.json" }, diff --git a/packages/connections-tools/package.json b/packages/connections-tools/package.json index 6599c951d5..3fb396a529 100644 --- a/packages/connections-tools/package.json +++ b/packages/connections-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/connections-tools", "private": true, "description": "The list_connections and request_connection tools as an @intx/agent tool bundle: an agent sees which third-party connections this workbench already has live and hands the human a deep link to connect one that isn't, reusing the existing connections plane and never inventing new state or completing OAuth itself", - "version": "0.0.9", + "version": "0.0.10", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/connections-tools/src/client.test.ts b/packages/connections-tools/src/client.test.ts index 72706263cd..c1d65ff402 100644 --- a/packages/connections-tools/src/client.test.ts +++ b/packages/connections-tools/src/client.test.ts @@ -1,80 +1,128 @@ import { expect, test } from "bun:test"; -import { listConnections, type ConnectionsToolClientConfig } from "./client"; +import { + listConnectedProviders, + type ConnectionsToolClientConfig, +} from "./client"; function testConfig(fetchImpl: typeof fetch): ConnectionsToolClientConfig { return { hubConnectionsUrl: "https://hub.example.com", + tenantId: "ten_1", sidecarToken: "sc-token", address: "run_1@workflow", fetchImpl, }; } -test("listConnections fetches the workflow-connections endpoint with sidecar auth", async () => { - let seenUrl: string | undefined; - let seenHeaders: Record | undefined; +/** Stock providers + credentials, keyed so only `granola` has a live + * credential: `linear`'s is revoked and `exa` has none at all. */ +function stockHub(): { + fetchImpl: typeof fetch; + urls: string[]; + headers: Record[]; +} { + const urls: string[] = []; + const headers: Record[] = []; const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seenUrl = String(url); - seenHeaders = init?.headers as Record; - return new Response( - JSON.stringify({ + urls.push(String(url)); + headers.push(init?.headers as Record); + if (String(url).includes("/providers")) { + return Response.json({ data: [ - { - id: "granola", - displayName: "Granola", - docsUrl: "https://www.granola.ai", - connected: true, - }, + { id: "prv_1", name: "granola" }, + { id: "prv_2", name: "linear" }, + { id: "prv_3", name: "exa" }, ], - }), - ); + nextCursor: null, + }); + } + return Response.json({ + data: [ + { id: "crd_1", providerId: "prv_1", status: "active" }, + { id: "crd_2", providerId: "prv_2", status: "revoked" }, + ], + nextCursor: null, + }); }) as unknown as typeof fetch; + return { fetchImpl, urls, headers }; +} - const connections = await listConnections(testConfig(fetchImpl)); +test("a connector counts as connected only with an active credential against its own provider", async () => { + const { fetchImpl, urls, headers } = stockHub(); - expect(seenUrl).toBe( - "https://hub.example.com/api/workflow-connections/connections", - ); - expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token"); - expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); - expect(connections).toEqual([ - { - id: "granola", - displayName: "Granola", - docsUrl: "https://www.granola.ai", - connected: true, - }, + const live = await listConnectedProviders(testConfig(fetchImpl)); + + expect([...live]).toEqual(["granola"]); + expect( + urls.some((u) => + u.startsWith("https://hub.example.com/api/tenants/ten_1/providers?"), + ), + ).toBe(true); + expect( + urls.some((u) => + u.startsWith("https://hub.example.com/api/tenants/ten_1/credentials?"), + ), + ).toBe(true); + expect(headers[0]?.["authorization"]).toBe("Bearer sc-token"); + expect(headers[0]?.["x-workflow-run-address"]).toBe("run_1@workflow"); +}); + +test("a credential past the first page still counts as connected", async () => { + const fetchImpl = (async (url: string | URL) => { + const raw = String(url); + if (raw.includes("/providers")) { + return Response.json({ + data: [{ id: "prv_9", name: "granola" }], + nextCursor: null, + }); + } + if (raw.includes("cursor=page_2")) { + return Response.json({ + data: [{ id: "crd_2", providerId: "prv_9", status: "active" }], + nextCursor: null, + }); + } + return Response.json({ + data: [{ id: "crd_1", providerId: "prv_other", status: "active" }], + nextCursor: "page_2", + }); + }) as unknown as typeof fetch; + + expect([...(await listConnectedProviders(testConfig(fetchImpl)))]).toEqual([ + "granola", ]); }); -test("listConnections throws an honest error on a non-ok HTTP response, never fabricating a result", async () => { +test("a non-ok HTTP response throws honestly, never fabricating a result", async () => { const fetchImpl = (async () => new Response("", { status: 500, statusText: "Internal Server Error", })) as unknown as typeof fetch; - await expect(listConnections(testConfig(fetchImpl))).rejects.toThrow(/500/); + await expect(listConnectedProviders(testConfig(fetchImpl))).rejects.toThrow( + /500/, + ); }); -test("listConnections throws on a response that doesn't match the expected shape", async () => { +test("a response that doesn't match the expected shape throws", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ nonsense: true }), )) as unknown as typeof fetch; - await expect(listConnections(testConfig(fetchImpl))).rejects.toThrow( - /expected shape/, + await expect(listConnectedProviders(testConfig(fetchImpl))).rejects.toThrow( + /unexpected shape/, ); }); -test("listConnections throws an honest error when the hub is unreachable", async () => { +test("an unreachable hub throws honestly", async () => { const fetchImpl = (async () => { throw new Error("fetch failed: connection refused"); }) as unknown as typeof fetch; - await expect(listConnections(testConfig(fetchImpl))).rejects.toThrow( + await expect(listConnectedProviders(testConfig(fetchImpl))).rejects.toThrow( /connection refused/, ); }); diff --git a/packages/connections-tools/src/client.ts b/packages/connections-tools/src/client.ts index fe00f4eba2..8ec1580bf4 100644 --- a/packages/connections-tools/src/client.ts +++ b/packages/connections-tools/src/client.ts @@ -1,41 +1,50 @@ -// A minimal client for the workflow-run-authenticated connections -// surface a running agent calls to see which third-party connectors -// this workbench already has live — the execution half of -// `@corbits/connections`'s `createWorkflowConnectionRoutes` -// (`packages/connections/src/workflow-connection-routes.ts`), mounted -// in `apps/hub` at `/api/workflow-connections` beside -// `/api/workflow-capabilities` and `/api/workflow-skills` — -// authenticated the same way, via a `WorkflowRunAuthenticator` (sidecar -// bearer token + run address), never a human browser session. +// A minimal client for the two hub surfaces this bundle reads. +// +// `list_connections` reads the STOCK Interchange tenant routes — +// `/api/tenants/:tenantId/providers` and `/credentials` — with the workflow +// run's own bearer credential (sidecar token plus run address), which the hub +// resolves to this run's principal and tenant. A connector counts as +// connected when the tenant has an active credential against a provider named +// for it, the same `provider.name = connector id` keying an agent launch +// resolves a credential by. There is no Workbench-specific +// `/api/workflow-connections` mirror any more. +// +// `request_connection` still posts its `connect-service` card through the +// workflow-chat participant route; that surface is unchanged. import { type } from "arktype"; export interface ConnectionsToolClientConfig { - /** The hub's plain HTTP origin — same value capability-tools' - * `hubCapabilitiesUrl` and memory-tools' `hubMemoryUrl` reach the hub - * through. */ + /** The hub's plain HTTP origin. */ readonly hubConnectionsUrl: string; + /** The run's own tenant — the `:tenantId` segment of a stock route. */ + readonly tenantId: string; readonly sidecarToken: string; readonly address: string; /** Override for tests; defaults to the global `fetch`. */ readonly fetchImpl?: typeof fetch; } -export type ConnectionStatus = { - readonly id: string; - readonly displayName: string; - readonly docsUrl: string; - readonly connected: boolean; -}; +/** Which connectors this tenant currently has a live credential for. + * Keyed by connector id — the caller pairs it with its own connector + * registry for display names and docs links, which the hub does not know. */ +export type ConnectedProviders = ReadonlySet; + +const ProvidersPage = type({ + data: type({ id: "string", name: "string" }).array(), + "nextCursor?": "string | null", +}); -const ConnectionsResponse = type({ +const CredentialsPage = type({ data: type({ id: "string", - displayName: "string", - docsUrl: "string", - connected: "boolean", + providerId: "string", + status: "'active'|'expired'|'revoked'", }).array(), + "nextCursor?": "string | null", }); +const PAGE_LIMIT = 100; + function authHeaders( config: ConnectionsToolClientConfig, ): Record { @@ -45,31 +54,78 @@ function authHeaders( }; } -/** Fetches every connector this workbench knows about, each flagged - * with whether the calling tenant currently has a live credential for - * it. Throws on any transport, HTTP, or shape failure — never - * fabricates a result. */ -export async function listConnections( +function tenantBase(config: ConnectionsToolClientConfig): string { + return `${config.hubConnectionsUrl}/api/tenants/${encodeURIComponent(config.tenantId)}`; +} + +/** Walks every page: a truncated first page would read as "not connected", + * a wrong answer rather than a slow one. */ +async function readAllPages( config: ConnectionsToolClientConfig, -): Promise { + path: string, + what: string, + parse: ( + body: unknown, + ) => + | { data: readonly T[]; nextCursor?: string | null | undefined } + | type.errors, +): Promise { const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch( - `${config.hubConnectionsUrl}/api/workflow-connections/connections`, - { headers: authHeaders(config) }, - ); - if (!response.ok) { - throw new Error( - `Fetching connections failed: ${response.status} ${response.statusText}`, + const items: T[] = []; + let cursor: string | undefined; + for (;;) { + const params = new URLSearchParams({ limit: String(PAGE_LIMIT) }); + if (cursor !== undefined) params.set("cursor", cursor); + const response = await doFetch( + `${tenantBase(config)}${path}?${params.toString()}`, + { headers: authHeaders(config) }, ); + if (!response.ok) { + throw new Error( + `${what} failed: ${response.status} ${response.statusText}`, + ); + } + const parsed = parse(await response.json()); + if (parsed instanceof type.errors) { + throw new Error( + `${what} came back in an unexpected shape: ${parsed.summary}`, + ); + } + items.push(...parsed.data); + const next = parsed.nextCursor; + if (next === undefined || next === null) return items; + cursor = next; } - const body: unknown = await response.json(); - const parsed = ConnectionsResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `Connections response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.data; +} + +/** Connector ids this tenant currently has a live credential for. Throws on + * any transport, HTTP, or shape failure — never fabricates a result. + * + * The stock provider listing already includes providers inherited from + * ancestor tenants; the stock credential listing does not, so a credential + * that only exists on a parent tenant reads as not connected here. Recorded + * as an upstream ask rather than papered over with a second query. */ +export async function listConnectedProviders( + config: ConnectionsToolClientConfig, +): Promise { + const [providers, credentials] = await Promise.all([ + readAllPages(config, "/providers", "Listing providers", (body) => + ProvidersPage(body), + ), + readAllPages(config, "/credentials", "Listing credentials", (body) => + CredentialsPage(body), + ), + ]); + const liveProviderIds = new Set( + credentials + .filter((row) => row.status === "active") + .map((row) => row.providerId), + ); + return new Set( + providers + .filter((row) => liveProviderIds.has(row.id)) + .map((row) => row.name), + ); } /** Thrown when the caller's run has no room of its own to post into — @@ -134,46 +190,3 @@ export async function postConnectServiceBlock( } return { messageId: parsed.id }; } - -export type McpServerConnectionStatus = { - readonly slug: string; - readonly name: string; - readonly url: string; -}; - -const McpServersResponse = type({ - data: type({ - slug: "string", - name: "string", - url: "string", - }).array(), -}); - -/** Fetches every MCP server this tenant has connected through Plugins — - * the same `/api/workflow-connections/mcp-servers` route - * `@corbits/mcp-tools`' `mcp_list_servers` reads, reused here rather - * than a second listing mechanism, so `list_connections` reports the - * same connected set an agent would see calling `mcp_list_servers` - * directly. Throws on any transport, HTTP, or shape failure. */ -export async function listMcpServerConnections( - config: ConnectionsToolClientConfig, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch( - `${config.hubConnectionsUrl}/api/workflow-connections/mcp-servers`, - { headers: authHeaders(config) }, - ); - if (!response.ok) { - throw new Error( - `Fetching MCP servers failed: ${response.status} ${response.statusText}`, - ); - } - const body: unknown = await response.json(); - const parsed = McpServersResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `MCP servers response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.data; -} diff --git a/packages/connections-tools/src/index.ts b/packages/connections-tools/src/index.ts index 4106102334..1a6afc6efb 100644 --- a/packages/connections-tools/src/index.ts +++ b/packages/connections-tools/src/index.ts @@ -1,6 +1,8 @@ export { - listConnections, - type ConnectionStatus, + listConnectedProviders, + NoOwnRoomError, + postConnectServiceBlock, + type ConnectedProviders, type ConnectionsToolClientConfig, } from "./client"; export { diff --git a/packages/connections-tools/src/tool.test.ts b/packages/connections-tools/src/tool.test.ts index 5e14ab35d1..4c200a0cec 100644 --- a/packages/connections-tools/src/tool.test.ts +++ b/packages/connections-tools/src/tool.test.ts @@ -58,6 +58,7 @@ const TEST_MCP_PRESETS: readonly McpPreset[] = [ function testEnv(): WorkflowConnectionEnv { return { hubConnectionsUrl: "https://hub.example.com", + tenantId: "ten_1", sidecarToken: "sc-token", address: "run_1@workflow", connectorRegistry: TEST_CONNECTOR_REGISTRY, @@ -79,17 +80,20 @@ test("declares exactly list_connections and request_connection, neither gated be test("requires the sanctioned workflow-connection env keys", () => { expect(connectionsTools.requires).toEqual([ "hubConnectionsUrl", + "tenantId", "sidecarToken", "address", ]); }); +/** Serves the stock tenant provider + credential routes, reporting every + * connector id in `live` as having an active credential. */ function stubFetch(opts: { - connections?: unknown[]; - mcpServers?: unknown[]; + live?: string[]; posted?: unknown[]; postStatus?: number; }): typeof fetch { + const live = opts.live ?? []; return (async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url.endsWith("/participants/messages")) { @@ -105,39 +109,26 @@ function stubFetch(opts: { { status: 201 }, ); } - if (url.endsWith("/mcp-servers")) { - return new Response(JSON.stringify({ data: opts.mcpServers ?? [] })); + if (url.includes("/providers")) { + return Response.json({ + data: live.map((name) => ({ id: `prv_${name}`, name })), + nextCursor: null, + }); } - return new Response(JSON.stringify({ data: opts.connections ?? [] })); + return Response.json({ + data: live.map((name) => ({ + id: `crd_${name}`, + providerId: `prv_${name}`, + status: "active", + })), + nextCursor: null, + }); }) as unknown as typeof fetch; } -test("list_connections summarizes connected and not-connected connectors from the client", async () => { +test("list_connections summarizes connected and not-connected connectors from the stock routes", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ - connections: [ - { - id: "github", - displayName: "GitHub", - docsUrl: "https://github.com/settings/tokens", - connected: true, - }, - { - id: "scrapecreators", - displayName: "ScrapeCreators", - docsUrl: "https://scrapecreators.com", - connected: false, - }, - ], - mcpServers: [ - { - slug: "fieldnotes", - name: "Fieldnotes", - url: "https://mcp.fieldnotes.example", - }, - { slug: "exa", name: "Exa", url: "https://mcp.exa.ai/mcp" }, - ], - }); + globalThis.fetch = stubFetch({ live: ["github", "exa"] }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -146,11 +137,9 @@ test("list_connections summarizes connected and not-connected connectors from th ); expect(result.isError).toBeFalsy(); expect(result.content).toMatch(/Connected: GitHub/); - expect(result.content).toMatch(/Exa \(via MCP\)/); - expect(result.content).toMatch(/Fieldnotes \(MCP server\)/); - expect(result.content).toMatch(/Not connected: [^.]*ScrapeCreators/); - expect(result.content).toMatch(/Granola/); - expect(result.content).not.toMatch(/Exa,/); // Exa never listed twice + expect(result.content).toMatch(/Connected: [^.]*Exa/); + expect(result.content).toMatch(/Not connected: [^.]*Granola/); + expect(result.content).toMatch(/Not connected: [^.]*Notion/); } finally { globalThis.fetch = originalFetch; } @@ -176,7 +165,7 @@ test("list_connections returns an honest error on an unreachable hub, never fabr test("request_connection tells the agent to keep helping for a name this workspace can't connect, never sending the human off to add servers", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ mcpServers: [] }); + globalThis.fetch = stubFetch({}); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -197,40 +186,10 @@ test("request_connection tells the agent to keep helping for a name this workspa } }); -test("request_connection reports an already-connected MCP server rather than re-requesting it", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ - mcpServers: [ - { slug: "notion", name: "Notion", url: "https://mcp.notion.example" }, - ], - }); - try { - const bundle = connectionsTools(testEnv()); - const result = await bundle.run( - callFor(REQUEST_CONNECTION_TOOL, { connector: "notion" }), - new AbortController().signal, - ); - expect(result.isError).toBeFalsy(); - expect(result.content).toMatch(/already connected/); - } finally { - globalThis.fetch = originalFetch; - } -}); - test("request_connection posts a connect-service card into the room for a registry connector", async () => { const originalFetch = globalThis.fetch; const posted: unknown[] = []; - globalThis.fetch = stubFetch({ - connections: [ - { - id: "github", - displayName: "GitHub", - docsUrl: "https://github.com/settings/tokens", - connected: false, - }, - ], - posted, - }); + globalThis.fetch = stubFetch({ posted }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -262,17 +221,7 @@ test("request_connection posts a connect-service card into the room for a regist test("request_connection reports an already-connected registry connector rather than posting a card", async () => { const originalFetch = globalThis.fetch; const posted: unknown[] = []; - globalThis.fetch = stubFetch({ - connections: [ - { - id: "github", - displayName: "GitHub", - docsUrl: "https://github.com/settings/tokens", - connected: true, - }, - ], - posted, - }); + globalThis.fetch = stubFetch({ live: ["github"], posted }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -290,7 +239,7 @@ test("request_connection reports an already-connected registry connector rather test("request_connection posts a connect-service card for a curated MCP preset, defaulting the reason from the preset", async () => { const originalFetch = globalThis.fetch; const posted: unknown[] = []; - globalThis.fetch = stubFetch({ mcpServers: [], posted }); + globalThis.fetch = stubFetch({ posted }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -318,7 +267,7 @@ test("request_connection posts a connect-service card for a curated MCP preset, test("request_connection hands over a plain link when the run has no room to post into", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ mcpServers: [], postStatus: 404 }); + globalThis.fetch = stubFetch({ postStatus: 404 }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -334,9 +283,7 @@ test("request_connection hands over a plain link when the run has no room to pos test("request_connection reports an already-connected preset rather than re-requesting it", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ - mcpServers: [{ slug: "exa", name: "Exa", url: "https://mcp.exa.ai/mcp" }], - }); + globalThis.fetch = stubFetch({ live: ["exa"] }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( diff --git a/packages/connections-tools/src/tool.ts b/packages/connections-tools/src/tool.ts index 2608617beb..56ab051932 100644 --- a/packages/connections-tools/src/tool.ts +++ b/packages/connections-tools/src/tool.ts @@ -8,13 +8,14 @@ // OAuth itself; only a human, acting in the browser through the // existing Connections settings surface, can finish a connect flow. // -// Both tools also read `@corbits/connections`' MCP-server listing -// (CL-6142's `/api/workflow-connections/mcp-servers`, backed by -// `@corbits/mcp-tools`' own `mcp_list_servers` route) — a tenant-minted -// `mcp:` connector has no fixed registry id, so `list_connections` -// folds it into the connected list by name, and `request_connection` -// falls back to it (see `ADD_MCP_SERVER_GUIDANCE`) before reporting an -// unknown connector. +// Live connections come from the STOCK Interchange tenant routes +// (providers + credentials), read with the run bearer; the tool pairs that +// answer with the deploying build's own connector registry for display +// names and links. MCP servers are not a stock Interchange concept, so a +// tenant-minted `mcp:` connection is no longer listed: a curated +// preset is still offered as a card, but the tool cannot say whether one is +// already connected (CL-8164 moves MCP server config to the connections +// library). // // Approval: `list_connections` reads only, so it declares no `approval` // key (matching a read-style tool, e.g. `@corbits/memory-tools`' search @@ -36,8 +37,7 @@ import type { McpPreset } from "@corbits/connections/mcp-presets"; import { type } from "arktype"; import { - listConnections, - listMcpServerConnections, + listConnectedProviders, NoOwnRoomError, postConnectServiceBlock, } from "./client"; @@ -52,6 +52,7 @@ export const REQUEST_CONNECTION_TOOL = "request_connection"; * definition). */ export interface WorkflowConnectionEnv extends BaseEnv { readonly hubConnectionsUrl: string; + readonly tenantId: string; readonly sidecarToken: string; readonly address: string; /** The deploying build's connector set — this package carries no @@ -81,6 +82,7 @@ function errorResult(callId: string, err: unknown): ToolResult { function clientConfig(env: WorkflowConnectionEnv) { return { hubConnectionsUrl: env.hubConnectionsUrl, + tenantId: env.tenantId, sidecarToken: env.sidecarToken, address: env.address, }; @@ -127,43 +129,24 @@ async function runListConnections( call: ToolCall, ): Promise { try { - const [connections, mcpServers] = await Promise.all([ - listConnections(clientConfig(env)), - listMcpServerConnections(clientConfig(env)), - ]); + const live = await listConnectedProviders(clientConfig(env)); const presetFronted = presetFrontedIds(env.mcpPresets); - const connectedMcpSlugs = new Set(mcpServers.map((server) => server.slug)); - const registryEntries = connections.filter( - (entry) => !presetFronted.has(entry.id), - ); + const registryEntries = Object.values(env.connectorRegistry) + .filter((descriptor) => !presetFronted.has(descriptor.id)) + .map((descriptor) => ({ + displayName: descriptor.displayName, + connected: live.has(descriptor.id), + })); const connected = registryEntries.filter((entry) => entry.connected); const notConnected = registryEntries.filter((entry) => !entry.connected); - const otherMcpServers = mcpServers.filter( - (server) => !presetFronted.has(server.slug), - ); - // A preset service counts as connected through EITHER door: its MCP - // server, or a plain key stored under the same connector id — a - // Granola key connected before the MCP card existed must never - // read as "Not connected". - const keyConnectedIds = new Set( - connections.filter((entry) => entry.connected).map((entry) => entry.id), - ); - const presetConnected = env.mcpPresets.filter( - (preset) => - connectedMcpSlugs.has(preset.slug) || keyConnectedIds.has(preset.slug), + const presetConnected = env.mcpPresets.filter((preset) => + live.has(preset.slug), ); const presetNotConnected = env.mcpPresets.filter( - (preset) => - !connectedMcpSlugs.has(preset.slug) && - !keyConnectedIds.has(preset.slug), + (preset) => !live.has(preset.slug), ); - if ( - connected.length === 0 && - notConnected.length === 0 && - otherMcpServers.length === 0 && - env.mcpPresets.length === 0 - ) { + if (registryEntries.length === 0 && env.mcpPresets.length === 0) { return { callId: call.id, isError: false, @@ -172,12 +155,7 @@ async function runListConnections( } const connectedNames = [ ...connected.map((entry) => entry.displayName), - ...presetConnected.map((preset) => - connectedMcpSlugs.has(preset.slug) - ? `${preset.displayName} (via MCP)` - : preset.displayName, - ), - ...otherMcpServers.map((server) => `${server.name} (MCP server)`), + ...presetConnected.map((preset) => preset.displayName), ]; const notConnectedNames = [ ...notConnected.map((entry) => entry.displayName), @@ -247,13 +225,11 @@ async function runRequestConnection( const preset = mcpPresetByName(env.mcpPresets, parsed.connector); if (preset !== undefined) { try { - const mcpServers = await listMcpServerConnections(clientConfig(env)); - const already = mcpServers.find((server) => server.slug === preset.slug); - if (already !== undefined) { + if ((await listConnectedProviders(clientConfig(env))).has(preset.slug)) { return { callId: call.id, isError: false, - content: `"${already.name}" is already connected as an MCP server.`, + content: `${preset.displayName} is already connected.`, }; } } catch (err) { @@ -276,11 +252,9 @@ async function runRequestConnection( const descriptor = env.connectorRegistry[parsed.connector]; if (descriptor !== undefined) { try { - const connections = await listConnections(clientConfig(env)); - const entry = connections.find( - (candidate) => candidate.id === descriptor.id, - ); - if (entry !== undefined && entry.connected) { + if ( + (await listConnectedProviders(clientConfig(env))).has(descriptor.id) + ) { return { callId: call.id, isError: false, @@ -304,24 +278,7 @@ async function runRequestConnection( ); } - // Not a fixed registry connector — check whether it is already a - // connected MCP server under this name before assuming it needs one. - try { - const mcpServers = await listMcpServerConnections(clientConfig(env)); - const already = mcpServers.find( - (server) => - server.slug === parsed.connector || server.name === parsed.connector, - ); - if (already !== undefined) { - return { - callId: call.id, - isError: false, - content: `"${already.name}" is already connected as an MCP server.`, - }; - } - } catch (err) { - return errorResult(call.id, err); - } + // Not a fixed registry connector and not a curated preset. return { callId: call.id, @@ -341,7 +298,7 @@ async function runRequestConnection( */ export const connectionsTools = defineTool({ id: "@corbits/connections-tools/conn", - requires: ["hubConnectionsUrl", "sidecarToken", "address"], + requires: ["hubConnectionsUrl", "tenantId", "sidecarToken", "address"], definitions: [ { name: LIST_CONNECTIONS_TOOL }, { name: REQUEST_CONNECTION_TOOL }, diff --git a/packages/connections/src/index.ts b/packages/connections/src/index.ts index 725ebf541d..d28c94242a 100644 --- a/packages/connections/src/index.ts +++ b/packages/connections/src/index.ts @@ -74,7 +74,6 @@ export { } from "./mcp-server-store"; export { createWorkflowConnectionRoutes, - type ConnectionSummary, type CreateWorkflowConnectionRoutesDeps, type WorkflowConnectionRunScope, type WorkflowRunAuthenticator as WorkflowConnectionRunAuthenticator, diff --git a/packages/connections/src/workflow-connection-routes.test.ts b/packages/connections/src/workflow-connection-routes.test.ts index 64145c98b0..55dfcdfa3a 100644 --- a/packages/connections/src/workflow-connection-routes.test.ts +++ b/packages/connections/src/workflow-connection-routes.test.ts @@ -4,37 +4,6 @@ import { createWorkflowConnectionRoutes, type WorkflowConnectionRunScope, } from "./workflow-connection-routes"; -import type { ConnectorRegistry } from "./registry"; - -/** A fixture standing in for the real connector set a build's own - * `templates/connectors.ts` carries — this package holds no concrete - * connector set of its own (CL-7384). */ -const TEST_REGISTRY: ConnectorRegistry = { - github: { - id: "github", - displayName: "GitHub", - authKind: "api-key", - credentialPlugin: "http", - docsUrl: "https://github.com/settings/tokens", - feedsTools: ["@corbits/github-tools"], - }, - linear: { - id: "linear", - displayName: "Linear", - authKind: "api-key", - credentialPlugin: "http-raw-authorization", - docsUrl: "https://linear.app/settings/api", - feedsTools: ["@corbits/linear-tools"], - }, - anthropic: { - id: "anthropic", - displayName: "Anthropic", - authKind: "api-key", - credentialPlugin: "http", - docsUrl: "https://console.anthropic.com/settings/keys", - feedsTools: [], - }, -}; const VALID_TOKEN = "sc-token"; const VALID_ADDRESS = "run_1@workflow"; @@ -55,11 +24,10 @@ describe("createWorkflowConnectionRoutes", () => { test("rejects a call with a missing or unrecognized sidecar bearer token / run address", async () => { const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async () => false, + listMcpServers: async () => [], }); - const response = await app.request("/connections", { + const response = await app.request("/mcp-servers", { headers: { authorization: "Bearer wrong-token", "x-workflow-run-address": VALID_ADDRESS, @@ -71,78 +39,40 @@ describe("createWorkflowConnectionRoutes", () => { expect(body.error.code).toBe("unauthorized"); }); - test("reports a non-inference connector (GitHub) connected once it has a real, verified credential", async () => { - // CL-6492: `list_connections` used to resolve every connector's - // status from the inference-only model catalog, so a verified - // GitHub PAT (never seeded into that catalog) always read "not - // connected" — the agent would refuse work it could actually do. - let seenTenantId: string | undefined; - let seenConnectorId: string | undefined; + test("never reads the MCP registry before authentication succeeds", async () => { + let called = false; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async (tenantId, connectorId) => { - if (connectorId === "github") { - seenTenantId = tenantId; - seenConnectorId = connectorId; - return true; - } - return false; + listMcpServers: async () => { + called = true; + return []; }, }); - const response = await app.request("/connections", { - headers: { - authorization: `Bearer ${VALID_TOKEN}`, - "x-workflow-run-address": VALID_ADDRESS, - }, + await app.request("/mcp-servers", { + headers: { authorization: "Bearer nope", "x-workflow-run-address": "x" }, }); - expect(response.status).toBe(200); - expect(seenTenantId).toBe("tenant_1"); - expect(seenConnectorId).toBe("github"); - const body = (await response.json()) as { - data: { id: string; displayName: string; connected: boolean }[]; - }; - const github = body.data.find((entry) => entry.id === "github"); - expect(github?.connected).toBe(true); + expect(called).toBe(false); }); - test("still reports an unconnected connector as not connected", async () => { + test("scopes the MCP-server listing to the authenticated run's own tenant", async () => { + let seenTenantId: string | undefined; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async () => false, - }); - - const response = await app.request("/connections", { - headers: { - authorization: `Bearer ${VALID_TOKEN}`, - "x-workflow-run-address": VALID_ADDRESS, + listMcpServers: async (tenantId) => { + seenTenantId = tenantId; + return [ + { + slug: "granola", + name: "Granola", + url: "https://mcp.granola.ai", + } as never, + ]; }, }); - expect(response.status).toBe(200); - const body = (await response.json()) as { - data: { id: string; displayName: string; connected: boolean }[]; - }; - const linear = body.data.find((entry) => entry.id === "linear"); - expect(linear?.connected).toBe(false); - // The full registry, not a filtered subset — an unconnected - // connector still gets a card the client can render a "not - // connected" state for. - expect(body.data.length).toBeGreaterThan(2); - }); - - test("keeps reporting an inference provider (Anthropic) connected", async () => { - const app = createWorkflowConnectionRoutes({ - authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async (_tenantId, connectorId) => - connectorId === "anthropic", - }); - - const response = await app.request("/connections", { + const response = await app.request("/mcp-servers", { headers: { authorization: `Bearer ${VALID_TOKEN}`, "x-workflow-run-address": VALID_ADDRESS, @@ -150,28 +80,8 @@ describe("createWorkflowConnectionRoutes", () => { }); expect(response.status).toBe(200); - const body = (await response.json()) as { - data: { id: string; displayName: string; connected: boolean }[]; - }; - const anthropic = body.data.find((entry) => entry.id === "anthropic"); - expect(anthropic?.connected).toBe(true); - }); - - test("never calls isConnectorConnected before authentication succeeds", async () => { - let called = false; - const app = createWorkflowConnectionRoutes({ - authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async () => { - called = true; - return false; - }, - }); - - await app.request("/connections", { - headers: { authorization: "Bearer nope", "x-workflow-run-address": "x" }, - }); - - expect(called).toBe(false); + expect(seenTenantId).toBe("tenant_1"); + const body = (await response.json()) as { data: { slug: string }[] }; + expect(body.data.map((row) => row.slug)).toEqual(["granola"]); }); }); diff --git a/packages/connections/src/workflow-connection-routes.ts b/packages/connections/src/workflow-connection-routes.ts index e28aa717e5..9c042236b5 100644 --- a/packages/connections/src/workflow-connection-routes.ts +++ b/packages/connections/src/workflow-connection-routes.ts @@ -1,45 +1,21 @@ -// The sanctioned path for a workflow-process child (Myra, or any -// conversational agent) to see which third-party connections this -// workbench has live, and to hand a human a link to connect one that -// isn't — CL-myra-manager-tools. Mirrors `@corbits/agent-directory`'s -// `createWorkflowCapabilityRoutes` and `@corbits/skills`' -// `createWorkflowSkillRoutes`: a workflow child has no browser session, -// only its sidecar bearer token and its own run address, so it -// authenticates through a `WorkflowRunAuthenticator` rather than the -// tenant-session pipeline `./routes.ts` uses. +// What is left of the workflow-run connections mirror: the MCP-server +// registry `@corbits/mcp-tools` resolves a `mcp:` server's URL +// through. MCP servers are not a stock Interchange concept, so there is no +// stock route to read them from; CL-8164 moves MCP server configuration into +// the connections/OAuth library and retires this mount with it. // -// Mounted OUTSIDE the tenant prefix for that reason, at -// `/api/workflow-connections`. Identity NEVER rides in a request body: -// the tenant every read is scoped to come from the authenticated run -// alone. -// -// `GET /connections` reports a connector connected through the same -// resolution an actual agent launch uses to deliver its credential — -// `@intx/db`'s `resolveCredentialRequirement`, keyed on the connector's -// registry `id` as its provider name (CL-6492). This is deliberately NOT -// `@corbits/chat`'s `listConnectedProviders`: that lister answers from the -// model catalog (`modelProvider`, seeded only for inference providers by -// `persistConnectorCredential`'s `seedCatalog` step), so every -// non-inference connector — GitHub, Linear, Notion, Sentry, Exa — reads -// "not connected" there even with a live, verified credential. Resolving -// through `resolveCredentialRequirement` instead covers both kinds -// uniformly: an inference provider's credential (however it was named — -// `persistConnectorCredential`'s own `displayName` row, or an onboarding -// seed's `-default` row) and a tool connector's credential both -// resolve the same way, by `provider.name = descriptor.id` and an active -// `credential` row against it, with no per-connector-kind branch here. +// `GET /connections` is gone: `@corbits/connections-tools` now reads live +// connections from the stock tenant provider and credential routes with the +// run bearer (CL-8159). // -// No `requireGrant` on `GET /connections`, unlike `./routes.ts`'s -// tenant-session routes: this endpoint is read-only and mutates nothing, -// so there is no write to gate. The companion `request_connection` tool -// (`@corbits/connections-tools`) needs no route at all — it validates a -// connector id against the same `CONNECTOR_REGISTRY` and builds a -// deep-link string, entirely in-process, never touching the network or -// completing OAuth itself — so this file only ever grows the one -// endpoint below. +// A workflow child has no browser session, only its sidecar bearer token and +// its own run address, so it authenticates through a +// `WorkflowRunAuthenticator`. Mounted OUTSIDE the tenant prefix for that +// reason, at `/api/workflow-connections`. Identity NEVER rides in a request +// body: the tenant the read is scoped to comes from the authenticated run +// alone. import { Hono } from "hono"; -import type { ConnectorDescriptor } from "./descriptor"; import type { McpServerConnection } from "./mcp-server-store"; /** @@ -71,38 +47,15 @@ export type WorkflowConnectionsEnv = { Variables: { workflowConnectionScope: WorkflowConnectionRunScope }; }; -export type ConnectionSummary = { - readonly id: string; - readonly displayName: string; - readonly docsUrl: string; - readonly connected: boolean; -}; - export type CreateWorkflowConnectionRoutesDeps = { readonly authenticator: WorkflowRunAuthenticator; - /** The connector set this build ships — this package carries none of - * its own (CL-7384), so a caller always supplies one. */ - readonly registry: Readonly>; - /** A port, not a raw `db` handle — keeps this package decoupled from - * the credentials schema by taking ports rather than reaching for - * database access directly. - * `apps/hub` supplies `@intx/db`'s `resolveCredentialRequirement`, - * curried over `db` and the `"tenant"` source (the same resolution - * `buildCredentialDelivery` uses at agent-launch time to decide whether - * a tool actually gets a credential), so this route reports exactly - * what an agent could really use — never a catalog-derived guess. */ - readonly isConnectorConnected: ( - tenantId: string, - connectorId: string, - ) => Promise; /** Backs `GET /mcp-servers` (`@corbits/mcp-tools`' `mcp_list_servers`): * every `mcp:` server this tenant has connected. `apps/hub` * supplies `@corbits/connections`' own `listMcpServerConnections` * (`mcp-server-store.ts`) — a direct DB read, since this route has no * tenant-session cookies to reuse `./mcp-server-routes.ts`'s hub-HTTP - * listing. Optional so an environment that hasn't wired MCP support - * yet degrades to an empty list rather than a route-mount error. */ - readonly listMcpServers?: ( + * listing. */ + readonly listMcpServers: ( tenantId: string, ) => Promise; }; @@ -135,26 +88,9 @@ export function createWorkflowConnectionRoutes( await next(); }); - app.get("/connections", async (c) => { - const scope = c.get("workflowConnectionScope"); - const descriptors = Object.values(deps.registry); - const connections: ConnectionSummary[] = await Promise.all( - descriptors.map(async (descriptor) => ({ - id: descriptor.id, - displayName: descriptor.displayName, - docsUrl: descriptor.docsUrl, - connected: await deps.isConnectorConnected( - scope.tenantId, - descriptor.id, - ), - })), - ); - return c.json({ data: connections }, 200); - }); - app.get("/mcp-servers", async (c) => { const scope = c.get("workflowConnectionScope"); - const servers = (await deps.listMcpServers?.(scope.tenantId)) ?? []; + const servers = await deps.listMcpServers(scope.tenantId); return c.json({ data: servers }, 200); }); diff --git a/workflows/assistant/test/manager-tools-scenario.test.ts b/workflows/assistant/test/manager-tools-scenario.test.ts index ae2ad0b8dd..7f077950f9 100644 --- a/workflows/assistant/test/manager-tools-scenario.test.ts +++ b/workflows/assistant/test/manager-tools-scenario.test.ts @@ -102,23 +102,25 @@ function createFakeHub() { ? (JSON.parse(init.body) as Record) : undefined; - if ( - url.pathname === "/api/workflow-connections/connections" && - method === "GET" - ) { + if (url.pathname.endsWith("/providers") && method === "GET") { return Response.json({ data: registry.map((entry) => ({ - ...entry, - connected: connected.has(entry.id), + id: `prv_${entry.id}`, + name: entry.id, })), + nextCursor: null, }); } - if ( - url.pathname === "/api/workflow-connections/mcp-servers" && - method === "GET" - ) { - return Response.json({ data: [] }); + if (url.pathname.endsWith("/credentials") && method === "GET") { + return Response.json({ + data: [...connected].map((id) => ({ + id: `crd_${id}`, + providerId: `prv_${id}`, + status: "active", + })), + nextCursor: null, + }); } if ( @@ -243,6 +245,7 @@ async function runScenario( ): Promise { const connectionsEnv: WorkflowConnectionEnv = { hubConnectionsUrl: HUB, + tenantId: "ten_1", sidecarToken: "sc-token", address: "run_myra@workflow", connectorRegistry: CONNECTOR_REGISTRY, @@ -314,7 +317,7 @@ async function runScenario( new AbortController().signal, ); expect(String(afterConnect.content)).toContain( - "Not connected: GitHub MCP, Notion, Sentry, Attio, Railway, PostHog, Sumble, Canva.", + "GitHub MCP, Notion, Sentry, Attio, Railway, PostHog, Sumble, Canva.", ); expect(String(afterConnect.content)).toContain( "Connected: Granola, Exa, Linear.", From 5f46b9b6b40e3869a283d563557f9df515bb2c2e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 10:34:24 -0700 Subject: [PATCH 4/4] chore: refresh the lockfile for the tool-package dependency changes Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- bun.lock | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index bd9f9432cb..d191fe19fd 100644 --- a/bun.lock +++ b/bun.lock @@ -612,7 +612,7 @@ }, "packages/connections-tools": { "name": "@corbits/connections-tools", - "version": "0.0.9", + "version": "0.0.10", "dependencies": { "@corbits/connections": "workspace:*", "@intx/agent": "workspace:*", @@ -3496,18 +3496,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], - - "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3528,12 +3518,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], - - "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], - - "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="],