Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
- name: Build the verified native TreeDX engine
working-directory: .treeseed/tools/treedx/apps/api
run: MIX_ENV=dev mix compile
- uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@69c07896708deaaed228cc6e14f12c41e5289981
- uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@3be2ed3324cfddf5405e3ff0303a11f561885e1b
env:
NODE_ENV: production
with:
Expand Down
27 changes: 7 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"@react-email/render": "^2.0.8",
"@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz",
"@treeseed/identity": "0.1.0-rc.19",
"@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#69c07896708deaaed228cc6e14f12c41e5289981",
"@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#3be2ed3324cfddf5405e3ff0303a11f561885e1b",
"@treeseed/treedx": "0.3.0-rc.18",
"drizzle-orm": "^0.45.2",
"hono": "4.13.3",
Expand Down
13 changes: 9 additions & 4 deletions src/api/auth/browser/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { protectedResourceMetadataSchema } from '@treeseed/sdk/identity';
import type { createApiIdentityRuntime } from './runtime.ts';
import { installIdentityBrowserRoutes } from './routes.ts';
import { installControlPlaneResourceRoutes } from '../../control-plane/http/resource-routes.ts';
import { controlPlaneOperations } from '../../control-plane/catalog/index.ts';

/** One resource server plus the workload-authenticated BFF bridge. It cannot
* issue passwords, API tokens or OAuth grants. The approved issuer does that.
Expand All @@ -13,10 +14,14 @@ export function installApiIdentityRoutes<E extends Env>(app: Hono<E>, runtime: A
mcpBusForPrincipal?: Parameters<typeof installControlPlaneResourceRoutes>[4];
}) {
const metadata = protectedResourceMetadataSchema.parse(runtime.metadata);
for (const path of ['/.well-known/oauth-protected-resource', '/.well-known/oauth-protected-resource/mcp']) {
app.get(path, c => c.json(metadata, 200, { 'cache-control': 'public, max-age=60' }));
}
const registry = options.registry ?? controlPlaneOperations;
const mcpScopes = new Set<string>(registry.catalog.operations
.filter(operation => operation.surfaces.includes('mcp_tool') || operation.surfaces.includes('mcp_resource'))
.flatMap(operation => operation.oauthScopes));
const mcpMetadata = { ...metadata, scopes_supported: (metadata.scopes_supported ?? []).filter(scope => mcpScopes.has(scope)) };
app.get('/.well-known/oauth-protected-resource', c => c.json(metadata, 200, { 'cache-control': 'public, max-age=60' }));
app.get('/.well-known/oauth-protected-resource/mcp', c => c.json(mcpMetadata, 200, { 'cache-control': 'public, max-age=60' }));
installIdentityBrowserRoutes(app, runtime);
return installControlPlaneResourceRoutes(app, runtime.authenticate, options.registry, options.confirmations,
return installControlPlaneResourceRoutes(app, runtime.authenticate, registry, options.confirmations,
options.mcpBusForPrincipal, metadata.resource);
}
2 changes: 1 addition & 1 deletion src/api/control-plane/mcp/create-mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,5 @@ export function createControlPlaneMcpHandler(registry: OperationRegistry, confir
}));
}
return server;
}, { legacy: 'reject', responseMode: 'auto', bus });
}, { legacy: 'stateless', responseMode: 'auto', bus });
}
48 changes: 45 additions & 3 deletions tests/unit/control-plane/accounts/identity/resource-http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { Hono } from 'hono';
import { expect, it, vi } from 'vitest';
import { decodeJwt, generateKeyPair, SignJWT } from 'jose';
import { BROWSER_SESSION_BRIDGE_PATH } from '@treeseed/sdk/identity';
import { CONTROL_PLANE_OPERATION_LIST } from '@treeseed/sdk/operator-contracts';
import { installApiIdentityRoutes } from '../../../../../src/api/auth/browser/api-routes.ts';
import { createIdentityAuthenticator } from '../../../../../src/api/auth/identity-authenticator.ts';
import { controlPlaneOperations } from '../../../../../src/api/control-plane/catalog/index.ts';
import { OperationRegistry } from '../../../../../src/api/control-plane/catalog/operation-registry.ts';

const issuer = 'https://identity.example/realms/treeseed', resource = 'https://api.example';
async function fixture() {
async function fixture(scopes = ['treeseed:read'], includeCommunication = false) {
const keys = await generateKeyPair('RS256');
const first = vi.fn(async (sql: string) => sql.includes('identity_workloads') ? null
: sql.includes('user_identities') ? { user_id: 'preserved-user', status: 'active' } : { id: 'preserved-user' });
Expand All @@ -20,11 +21,12 @@ async function fixture() {
const observed: Array<{ clientId: string; expiresAt: number | undefined }> = [];
const status = controlPlaneOperations.require('status.show');
installApiIdentityRoutes(app, { authenticate, services: new Map(), metadata: {
resource, authorization_servers: [issuer], bearer_methods_supported: ['header'], scopes_supported: ['treeseed:read'],
resource, authorization_servers: [issuer], bearer_methods_supported: ['header'], scopes_supported: scopes,
} }, { registry: new OperationRegistry([{ ...status, handler: async (input, context) => {
if (context.authInfo) observed.push({ clientId: context.authInfo.clientId, expiresAt: context.authInfo.expiresAt });
return status.handler(input, context);
} }]) });
} }, ...(includeCommunication ? [{ binding: CONTROL_PLANE_OPERATION_LIST.find(operation => operation.descriptor.operationId === 'communications.send')!,
handler: async () => { throw new Error('Metadata reads must not invoke a mutation.'); } }] : [])]) });
const token = (audience = resource, scope = 'treeseed:read', clientId: unknown = 'admin') => new SignJWT({ typ: 'Bearer', azp: clientId, scope })
.setProtectedHeader({ alg: 'RS256' }).setIssuer(issuer).setAudience(audience).setSubject('exact-subject')
.setIssuedAt().setExpirationTime('47s').sign(keys.privateKey);
Expand All @@ -43,6 +45,46 @@ it('publishes configured resource metadata, never the incoming Host or an API-lo
}
});

it('advertises only configured scopes of exposed MCP operations, preserving the general API metadata', async () => {
const scopes = ['treeseed:read', 'treeseed:admin', 'treeseed:execution', 'treeseed:projects:write'];
const { app } = await fixture(scopes, true);
const api = await (await app.request('/.well-known/oauth-protected-resource')).json();
const mcp = await (await app.request('/.well-known/oauth-protected-resource/mcp')).json();
expect(api).toMatchObject({ resource, scopes_supported: scopes });
expect(mcp).toMatchObject({ resource, authorization_servers: [issuer], scopes_supported: ['treeseed:read', 'treeseed:execution'] });
});

it('serves the native coordinator 2025 handshake, discovery and shared backend read with verified Identity tokens', async () => {
const { app, token, observed } = await fixture();
const bearer = await token(resource, 'treeseed:read', 'native-coordinator');
const request = (message: unknown, authorization = `Bearer ${bearer}`) => app.request('https://api.example/mcp', {
method: 'POST', headers: { host: 'api.example', authorization, 'content-type': 'application/json',
accept: 'application/json, text/event-stream', 'mcp-protocol-version': '2025-06-18' }, body: JSON.stringify(message),
});
const payload = async (response: Response) => {
if (response.headers.get('content-type')?.includes('application/json')) return response.json();
expect(response.headers.get('content-type')).toContain('text/event-stream');
return JSON.parse((await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n'));
};
const initialize = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {
protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'native-coordinator', version: '1.0.0' },
} };
const initialized = await request(initialize);
expect(initialized.status).toBe(200);
expect(await payload(initialized)).toMatchObject({ id: 1, result: { protocolVersion: '2025-06-18',
serverInfo: { name: 'treeseed-control-plane' } } });
const listed = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
expect(listed.status).toBe(200);
expect(await payload(listed)).toMatchObject({ id: 2, result: { tools: [expect.objectContaining({ name: 'status.show' })] } });
const called = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: {
name: 'status.show', arguments: { path: {}, query: {} },
} });
expect(called.status).toBe(200);
expect(await payload(called)).toMatchObject({ id: 3, result: { structuredContent: expect.any(Object) } });
expect(observed).toEqual([{ clientId: 'native-coordinator', expiresAt: expect.any(Number) }]);
expect((await request(initialize, 'Bearer invalid')).status).toBe(401);
});

it('uses verified Identity tokens for existing resource operations and denies wrong audiences without database reads', async () => {
const { app, token, first } = await fixture();
expect((await app.request('/v1/status', { headers: { authorization: `Bearer ${await token()}` } })).status).toBe(200);
Expand Down
Loading
Loading