Skip to content
Merged
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
12 changes: 7 additions & 5 deletions apps/console/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ roots across tenants, and prevents symlink escapes. The legacy
`COMMONPLACE_WORKSPACE_ALLOWED_ROOTS` variable is a single-tenant development
fallback only. Production must configure the Console list and tenant map.

Proactivity's GraphQL projection uses the CommonPlace API configured by
`CONSOLE_HARNESS_URL`. Its SSE feed is a separate RustyRed server endpoint:
set `THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered
`/v1/proactivity/stream` host. Do not fall back to `CONSOLE_HARNESS_URL`,
which would silently target the wrong deployment.
Proactivity and Filing use the CommonPlace consumer GraphQL schema configured by
`THEOREM_GRAPHQL_URL`. The value may be either the CommonPlace API origin or its
full `/graphql` endpoint. They never fall back to `CONSOLE_HARNESS_URL`, which
identifies the Harness MCP deployment and does not own those fields.
Proactivity's SSE feed is a separate RustyRed server endpoint: set
`THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered
`/v1/proactivity/stream` host.

## Composition doctrine

Expand Down
12 changes: 7 additions & 5 deletions apps/console/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ roots across tenants, and prevents symlink escapes. The legacy
`COMMONPLACE_WORKSPACE_ALLOWED_ROOTS` variable is a single-tenant development
fallback only. Production must configure the Console list and tenant map.

Proactivity's GraphQL projection uses the CommonPlace API configured by
`CONSOLE_HARNESS_URL`. Its SSE feed is a separate RustyRed server endpoint:
set `THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered
`/v1/proactivity/stream` host. Do not fall back to `CONSOLE_HARNESS_URL`,
which would silently target the wrong deployment.
Proactivity and Filing use the CommonPlace consumer GraphQL schema configured by
`THEOREM_GRAPHQL_URL`. The value may be either the CommonPlace API origin or its
full `/graphql` endpoint. They never fall back to `CONSOLE_HARNESS_URL`, which
identifies the Harness MCP deployment and does not own those fields.
Proactivity's SSE feed is a separate RustyRed server endpoint: set
`THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered
`/v1/proactivity/stream` host.

## Composition doctrine

Expand Down
2 changes: 1 addition & 1 deletion apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"start:railway": "node scripts/start-railway.mjs",
"start": "next start",
"lint": "eslint src scripts next.config.ts",
"test": "vitest run",
"test": "vitest run && node --test scripts/railway-env.test.mjs",
"test:watch": "vitest",
"gate:fence": "node scripts/check-import-fence.mjs",
"gate:register": "node scripts/check-register-lint.mjs",
Expand Down
12 changes: 12 additions & 0 deletions apps/console/scripts/railway-env.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SOURCING: none. Railway startup configuration validation.

/** Fail before the Console server starts when its production data plane is
* incomplete. Local development does not use the Railway launcher, so its
* localhost fallback remains available. */
export function assertRailwayEnvironment(environment = process.env) {
if (!environment.CONSOLE_DATA_API_URL?.trim()) {
throw new Error(
'CONSOLE_DATA_API_URL is required for CommonPlace Console Railway startup.',
);
}
}
25 changes: 25 additions & 0 deletions apps/console/scripts/railway-env.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { assertRailwayEnvironment } from './railway-env.mjs';

describe('assertRailwayEnvironment', () => {
it('requires the production data API URL', () => {
assert.throws(
() => assertRailwayEnvironment({}),
/CONSOLE_DATA_API_URL is required/,
);
assert.throws(
() => assertRailwayEnvironment({ CONSOLE_DATA_API_URL: ' ' }),
/CONSOLE_DATA_API_URL is required/,
);
});

it('accepts an explicit data API URL', () => {
assert.doesNotThrow(() =>
assertRailwayEnvironment({
CONSOLE_DATA_API_URL: 'http://commonplace-api.railway.internal:8080',
}),
);
});
});
3 changes: 3 additions & 0 deletions apps/console/scripts/start-railway.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertRailwayEnvironment } from './railway-env.mjs';

assertRailwayEnvironment();

const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');

Expand Down
31 changes: 31 additions & 0 deletions apps/console/src/lib/server/consumer-graphql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';

vi.mock('server-only', () => ({}));

import { consumerGraphqlUrl } from './consumer-graphql';

describe('consumerGraphqlUrl', () => {
it('uses the explicit CommonPlace consumer GraphQL endpoint', () => {
expect(
consumerGraphqlUrl({
THEOREM_GRAPHQL_URL: ' https://commonplace.example/graphql/ ',
}),
).toBe('https://commonplace.example/graphql');
});

it('accepts the existing CommonPlace base URL contract', () => {
expect(
consumerGraphqlUrl({
THEOREM_GRAPHQL_URL: 'https://commonplace.example/',
}),
).toBe('https://commonplace.example/graphql');
});

it('does not fall back to the Harness MCP host', () => {
expect(
consumerGraphqlUrl({
CONSOLE_HARNESS_URL: 'https://api.theoremharness.com',
}),
).toBeNull();
});
});
18 changes: 18 additions & 0 deletions apps/console/src/lib/server/consumer-graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SOURCING: none. Server-only consumer GraphQL endpoint selection.

Comment thread
Copilot marked this conversation as resolved.
import 'server-only';

/**
* Proactivity and Filing belong to the CommonPlace consumer schema. They must
* not fall back to CONSOLE_HARNESS_URL, which is the Harness MCP service and
* does not own their fields.
*/
export function consumerGraphqlUrl(
environment: Readonly<Record<string, string | undefined>> = process.env,
): string | null {
const configured = environment.THEOREM_GRAPHQL_URL?.trim();
if (!configured) return null;

const base = configured.replace(/\/+$/, '');
return base.endsWith('/graphql') ? base : `${base}/graphql`;
}
16 changes: 4 additions & 12 deletions apps/console/src/lib/server/filing-harness.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// SOURCING: none. Server-only GraphQL adapter for the filing engine's Index
// projection and its reversible corrections. It is the sole module that knows
// the upstream credential and tenant headers, matching the shape
// proactivity-harness.ts established.
// proactivity-harness.ts established. Filing belongs to the CommonPlace
// consumer GraphQL schema, not the Harness MCP schema.

import 'server-only';

Expand All @@ -13,6 +14,7 @@ import type {
IndexCollection,
UrgentEvent,
} from '@/lib/filing/types';
import { consumerGraphqlUrl } from '@/lib/server/consumer-graphql';
import { startHarnessRequestTimeout } from '@/lib/server/harness-timeout';
import {
principalTenantHeaders,
Expand Down Expand Up @@ -97,13 +99,6 @@ const EXPLAIN_QUERY = `
}
`;

function graphqlUrl(): string | null {
const explicit = process.env.THEOREM_GRAPHQL_URL;
if (explicit) return explicit;
const base = process.env.CONSOLE_HARNESS_URL;
return base ? `${base.replace(/\/$/, '')}/graphql` : null;
}

async function executeGraphql(
query: string,
variables: Record<string, unknown> = {},
Expand All @@ -115,7 +110,7 @@ async function executeGraphql(
if (!resolution.ok) {
return { ok: false, status: resolution.response.status, error: 'principal_resolution=unauthenticated' };
}
const endpoint = graphqlUrl();
const endpoint = consumerGraphqlUrl();
if (!endpoint) return { ok: false, status: 404, error: 'filing_graphql_unconfigured' };
const timeout = startHarnessRequestTimeout();
try {
Expand All @@ -124,9 +119,6 @@ async function executeGraphql(
headers: {
'Content-Type': 'application/json',
...principalTenantHeaders(resolution.principal),
...(process.env.CONSOLE_HARNESS_TOKEN
? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` }
: {}),
...(process.env.THEOREM_API_KEY ? { 'x-api-key': process.env.THEOREM_API_KEY } : {}),
},
body: JSON.stringify({ query, variables }),
Expand Down
181 changes: 181 additions & 0 deletions apps/console/src/lib/server/harness-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { principalTenantHeadersMock, resolveHarnessPrincipalMock } = vi.hoisted(() => ({
principalTenantHeadersMock: vi.fn(),
resolveHarnessPrincipalMock: vi.fn(),
}));

vi.mock('server-only', () => ({}));
vi.mock('@/lib/server/harness-principal', () => ({
principalTenantHeaders: principalTenantHeadersMock,
resolveHarnessPrincipal: resolveHarnessPrincipalMock,
}));

import { callHarnessMcp } from './harness-mcp';

const principal = {
tenant: 'Travis-Gilbert',
githubLogin: 'Travis-Gilbert',
harnessIdentity: 'github:owner',
};

function sse(payload: Record<string, unknown>, headers: Record<string, string> = {}): Response {
return new Response(`:\n\ndata: ${JSON.stringify(payload)}\n\n`, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
...headers,
},
});
}

function notificationThenMultilineSse(
notification: Record<string, unknown>,
payload: Record<string, unknown>,
): Response {
const serialized = JSON.stringify(payload);
const splitAt = serialized.indexOf(',') + 1;
return new Response(
`data: ${JSON.stringify(notification)}\n\n`
+ `:\n\ndata: ${serialized.slice(0, splitAt)}\ndata: ${serialized.slice(splitAt)}\n\n`,
{
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
},
);
}

beforeEach(() => {
vi.stubEnv('CONSOLE_HARNESS_URL', 'https://api.theoremharness.com');
vi.stubEnv('CONSOLE_HARNESS_TOKEN', 'test-harness-token');
resolveHarnessPrincipalMock.mockResolvedValue({ ok: true, principal });
principalTenantHeadersMock.mockReturnValue({
'x-theorem-tenant': principal.tenant,
'x-theorem-principal': principal.harnessIdentity,
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('callHarnessMcp', () => {
it('initializes a principal-bound MCP session before calling a tool', async () => {
const fetchMock = vi.fn()
.mockImplementationOnce((_input: string, init: RequestInit) => {
const id = JSON.parse(String(init.body)).id as string;
return Promise.resolve(sse({
jsonrpc: '2.0',
id,
result: {
protocolVersion: '2025-03-26',
serverInfo: { name: 'theorem-mcp-server', version: '0.1.0' },
},
}, { 'MCP-Session-Id': 'session-1' }));
})
.mockResolvedValueOnce(new Response(null, { status: 202 }))
.mockImplementationOnce((_input: string, init: RequestInit) => {
const id = JSON.parse(String(init.body)).id as string;
return Promise.resolve(notificationThenMultilineSse(
{
jsonrpc: '2.0',
method: 'notifications/progress',
params: { progressToken: 'probe', progress: 0.5 },
},
{
jsonrpc: '2.0',
id,
result: {
structuredContent: {
data: { observedModel: { eventCount: 0 } },
},
},
},
));
})
.mockResolvedValueOnce(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

const result = await callHarnessMcp('graphql_query', {
query: 'query { observedModel(topicId: "topic") }',
});

expect(result).toEqual({
ok: true,
data: { data: { observedModel: { eventCount: 0 } } },
principal,
});
expect(fetchMock).toHaveBeenCalledTimes(4);

const initialize = fetchMock.mock.calls[0] as [string, RequestInit];
expect(initialize[1].headers).toMatchObject({
Accept: 'application/json, text/event-stream',
Authorization: 'Bearer test-harness-token',
'MCP-Protocol-Version': '2025-06-18',
});
expect(JSON.parse(String(initialize[1].body))).toMatchObject({
method: 'initialize',
params: { protocolVersion: '2025-06-18' },
});

const ready = fetchMock.mock.calls[1] as [string, RequestInit];
expect(ready[1].headers).toMatchObject({
'MCP-Protocol-Version': '2025-03-26',
'MCP-Session-Id': 'session-1',
});
expect(JSON.parse(String(ready[1].body))).toMatchObject({
method: 'notifications/initialized',
});

const toolCall = fetchMock.mock.calls[2] as [string, RequestInit];
expect(toolCall[1].headers).toMatchObject({
'MCP-Protocol-Version': '2025-03-26',
'MCP-Session-Id': 'session-1',
});
expect(JSON.parse(String(toolCall[1].body))).toMatchObject({
method: 'tools/call',
params: {
name: 'graphql_query',
arguments: {
tenant: principal.tenant,
tenant_slug: principal.tenant,
actor: principal.harnessIdentity,
},
},
});

const close = fetchMock.mock.calls[3] as [string, RequestInit];
expect(close[1]).toMatchObject({
method: 'DELETE',
headers: expect.objectContaining({
'MCP-Protocol-Version': '2025-03-26',
'MCP-Session-Id': 'session-1',
}),
});
expect(close[1].signal).not.toBe(toolCall[1].signal);
});

it('fails closed when initialization does not return a session id', async () => {
const fetchMock = vi.fn().mockResolvedValue(sse({
jsonrpc: '2.0',
id: 'initialize',
result: {
protocolVersion: '2025-06-18',
serverInfo: { name: 'theorem-mcp-server', version: '0.1.0' },
},
}));
vi.stubGlobal('fetch', fetchMock);

const result = await callHarnessMcp('graphql_query', { query: 'query { status }' });

expect(result.ok).toBe(false);
if (!result.ok) {
await expect(result.response.json()).resolves.toMatchObject({
error: 'harness_mcp_initialization_failed',
});
}
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
Loading
Loading