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
9 changes: 9 additions & 0 deletions apps/console/src/lib/server/consumer-graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ vi.mock('server-only', () => ({}));
import { consumerGraphqlUrl } from './consumer-graphql';

describe('consumerGraphqlUrl', () => {
it('prefers CONSOLE_DATA_API_URL as the single data door', () => {
expect(
consumerGraphqlUrl({
CONSOLE_DATA_API_URL: ' https://commonplace.example/ ',
THEOREM_GRAPHQL_URL: 'https://stale.example/graphql',
}),
).toBe('https://commonplace.example/graphql');
});

it('uses the explicit CommonPlace consumer GraphQL endpoint', () => {
expect(
consumerGraphqlUrl({
Expand Down
13 changes: 8 additions & 5 deletions apps/console/src/lib/server/consumer-graphql.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// SOURCING: none. Server-only consumer GraphQL endpoint selection.
// SOURCING: none. Server-side consumer GraphQL endpoint selection.
// HANDOFF-CONSOLE-SINGLE-DOOR-1.0: CONSOLE_DATA_API_URL is the only data door.

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.
* Proactivity, Filing, Indexer, and RustyWeb search belong to the CommonPlace
* consumer schema. They must not fall back to CONSOLE_HARNESS_URL, which is the
* Harness MCP agent door 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();
const configured =
environment.CONSOLE_DATA_API_URL?.trim()
|| environment.THEOREM_GRAPHQL_URL?.trim();
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve credentials for existing consumer GraphQL callers

When CONSOLE_DATA_API_URL is configured, this redirects every consumerGraphqlUrl() caller to commonplace-api, but proactivity-harness.ts and filing-harness.ts still authenticate exclusively with THEOREM_API_KEY rather than CONSOLE_DATA_API_KEY. In the documented Railway configuration where the data API has its own key, both existing surfaces will receive 403 responses; either scope the new endpoint selection to Indexer/search or migrate those callers' credentials simultaneously.

AGENTS.md reference: apps/console/AGENTS.md:L33-L36

Useful? React with 👍 / 👎.

if (!configured) return null;

const base = configured.replace(/\/+$/, '');
Comment on lines +14 to 19
Expand Down
105 changes: 59 additions & 46 deletions apps/console/src/lib/server/indexer-harness.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { callHarnessGraphqlMock } = vi.hoisted(() => ({
callHarnessGraphqlMock: vi.fn(),
const {
resolveHarnessPrincipalMock,
resolveUpstreamCredentialMock,
fetchMock,
} = vi.hoisted(() => ({
resolveHarnessPrincipalMock: vi.fn(),
resolveUpstreamCredentialMock: vi.fn(),
fetchMock: vi.fn(),
}));

vi.mock('server-only', () => ({}));
vi.mock('@/lib/server/harness-graphql', () => ({
callHarnessGraphql: callHarnessGraphqlMock,
vi.mock('@/lib/server/harness-principal', () => ({
resolveHarnessPrincipal: resolveHarnessPrincipalMock,
principalTenantHeaders: () => ({ 'x-theorem-tenant': 'Travis-Gilbert' }),
}));
vi.mock('@/lib/server/upstream-credential', () => ({
resolveUpstreamCredential: resolveUpstreamCredentialMock,
credentialHeaders: () => ({ 'x-api-key': 'test-key' }),
}));
vi.mock('@/lib/server/consumer-graphql', () => ({
consumerGraphqlUrl: () => 'https://data.example/graphql',
}));
vi.mock('@/lib/server/harness-timeout', () => ({
startHarnessRequestTimeout: () => ({
signal: undefined,
didTimeout: () => false,
clear: () => undefined,
}),
}));

import { readIndexerObjects, readIndexerPreviewAsset } from './indexer-harness';
import { readIndexerObjects } from './indexer-harness';

const principal = {
tenant: 'Travis-Gilbert',
Expand All @@ -18,30 +39,43 @@ const principal = {
};

beforeEach(() => {
callHarnessGraphqlMock.mockReset();
resolveHarnessPrincipalMock.mockReset();
resolveUpstreamCredentialMock.mockReset();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
resolveHarnessPrincipalMock.mockResolvedValue({ ok: true, principal });
resolveUpstreamCredentialMock.mockResolvedValue({
ok: true,
credential: { kind: 'service', key: 'test-key' },
});
});

describe('Indexer Harness GraphQL transport', () => {
it('reads Indexer objects through the shared MCP GraphQL door', async () => {
callHarnessGraphqlMock.mockResolvedValue({
describe('Indexer consumer GraphQL transport', () => {
it('reads Indexer objects through CONSOLE_DATA_API GraphQL', async () => {
fetchMock.mockResolvedValue({
ok: true,
principal,
data: {
topicIndexerObjects: {
objects: [{
id: 'topic:one',
type: 'topic',
properties: { title: 'One' },
}],
status: 200,
json: async () => ({
data: {
topicIndexerObjects: {
objects: [{
id: 'topic:one',
type: 'topic',
properties: { title: 'One' },
}],
},
},
},
}),
});

const result = await readIndexerObjects({ topicId: 'one' });

expect(callHarnessGraphqlMock).toHaveBeenCalledWith(
expect.stringContaining('topicIndexerObjects'),
{ topicId: 'one', includeCaptures: true },
expect(fetchMock).toHaveBeenCalledWith(
'https://data.example/graphql',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('topicIndexerObjects'),
}),
);
expect(result).toEqual({
ok: true,
Expand All @@ -54,38 +88,17 @@ describe('Indexer Harness GraphQL transport', () => {
});
});

it('decodes an allowlisted preview returned through MCP GraphQL', async () => {
callHarnessGraphqlMock.mockResolvedValue({
ok: true,
principal,
data: {
topicPreviewAsset: {
content_type: 'image/png',
bytes_base64: 'aGk=',
},
},
});

const result = await readIndexerPreviewAsset('0a');

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.contentType).toBe('image/png');
expect([...result.bytes]).toEqual([104, 105]);
}
});

it('maps shared transport failures to the Indexer vocabulary', async () => {
callHarnessGraphqlMock.mockResolvedValue({
it('maps transport failures to the Indexer vocabulary', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 504,
error: 'harness_graphql_timeout',
json: async () => ({ errors: [{ message: 'timeout' }] }),
});
Comment on lines +91 to 96

await expect(readIndexerObjects({})).resolves.toEqual({
ok: false,
status: 504,
error: 'indexer_graphql_timeout',
error: 'timeout',
});
});
});
89 changes: 84 additions & 5 deletions apps/console/src/lib/server/indexer-harness.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
// SOURCING: none. Server-only GraphQL adapter for the Indexer projection
// (`topicIndexerObjects`). The browser never talks to Theorem directly.
// (`topicIndexerObjects`) over CONSOLE_DATA_API_URL.
// HANDOFF-CONSOLE-SINGLE-DOOR-1.0: no CONSOLE_HARNESS_* on this path.

import 'server-only';

import type { JsonValue, ObjectRef } from '@commonplace/block-view/types';
import { callHarnessGraphql } from '@/lib/server/harness-graphql';
import { consumerGraphqlUrl } from '@/lib/server/consumer-graphql';
import { startHarnessRequestTimeout } from '@/lib/server/harness-timeout';
import {
principalTenantHeaders,
resolveHarnessPrincipal,
} from '@/lib/server/harness-principal';
import {
credentialHeaders,
resolveUpstreamCredential,
} from '@/lib/server/upstream-credential';

export type IndexerRead =
| { readonly ok: true; readonly tenant: string; readonly objects: readonly ObjectRef[] }
Expand Down Expand Up @@ -40,6 +50,72 @@ function objectsFromPayload(data: Record<string, unknown>): ObjectRef[] {
}));
}

async function executeConsumerGraphql(
query: string,
variables: Record<string, unknown>,
): Promise<
| { readonly ok: true; readonly tenant: string; readonly data: Record<string, unknown> }
| { readonly ok: false; readonly status: number; readonly error: string }
> {
const resolution = await resolveHarnessPrincipal();
if (!resolution.ok) {
return {
ok: false,
status: resolution.response.status,
error: 'principal_resolution=unauthenticated',
};
}
const endpoint = consumerGraphqlUrl();
if (!endpoint) return { ok: false, status: 404, error: 'indexer_graphql_unconfigured' };

const credential = await resolveUpstreamCredential(resolution.principal);
if (!credential.ok) {
return { ok: false, status: 403, error: 'indexer_credential_unavailable' };
}

const timeout = startHarnessRequestTimeout();
try {
const upstream = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...credentialHeaders(credential.credential),
...principalTenantHeaders(resolution.principal),
},
body: JSON.stringify({ query, variables }),
cache: 'no-store',
signal: timeout.signal,
});
const payload = await upstream.json().catch(() => null) as {
data?: Record<string, unknown>;
errors?: Array<{ message?: unknown }>;
} | null;
if (!upstream.ok || payload?.errors || !payload?.data) {
const detail = payload?.errors?.[0]?.message;
return {
ok: false,
status: upstream.ok ? 502 : upstream.status,
error: typeof detail === 'string' ? detail : indexerTransportError(upstream.status, timeout.didTimeout()),
};
Comment on lines +93 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'apps/console/src/lib/server/(indexer-harness|web-research)\.ts$' || true

echo
echo "indexer-harness outline:"
ast-grep outline apps/console/src/lib/server/indexer-harness.ts --view expanded || true

echo
echo "web-research outline:"
ast-grep outline apps/console/src/lib/server/web-research.ts --view expanded || true

echo
echo "Relevant slices:"
wc -l apps/console/src/lib//.
sed -n '1,140p' apps/console/src/lib/server/indexer-harness.ts
echo '--- web-research 1-170 ---'
sed -n '1,170p' apps/console/src/lib/server/web-research.ts

Repository: Travis-Gilbert/CommonPlace

Length of output: 1511


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- indexer-harness 93-130, 174-210 ---'
sed -n '93,130p' apps/console/src/lib/server/indexer-harness.ts
sed -n '174,210p' apps/console/src/lib/server/indexer-harness.ts

echo
echo '--- web-research 120-145 ---'
sed -n '120,145p' apps/console/src/lib/server/web-research.ts

echo
echo '--- GraphQL error message fields in consumer-graphql helpers ---'
rg -n "errors\\?\\.\\[0\\]\\.message|envelope\\?.*errors|GRAPHQL|THEOREM|CONSOLE_HARNESS|graphql_query" apps/console/src/lib/server apps/console/src -g '*.ts' -g '*.tsx' | head -200

Repository: Travis-Gilbert/CommonPlace

Length of output: 19733


Do not expose raw upstream GraphQL error messages. Both executeConsumerGraphql and loadWebResearch return errors[0].message directly to clients when it is a string; log that detail server-side and return fixed local error text/codes (indexerTransportError(...) for the indexer path and the existing generic refusal message for web research) instead.

📍 Affects 2 files
  • apps/console/src/lib/server/indexer-harness.ts#L93-L99 (this comment)
  • apps/console/src/lib/server/web-research.ts#L122-L134
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/lib/server/indexer-harness.ts` around lines 93 - 99, Stop
returning raw upstream GraphQL messages from executeConsumerGraphql and
loadWebResearch. In apps/console/src/lib/server/indexer-harness.ts lines 93-99,
log the extracted detail server-side and always return the fixed
indexerTransportError(...) result; in
apps/console/src/lib/server/web-research.ts lines 122-134, log the upstream
detail and return the existing generic refusal message instead.

}
Comment on lines +93 to +100
return { ok: true, tenant: resolution.principal.tenant, data: payload.data };
} catch {
return {
ok: false,
status: timeout.didTimeout() ? 504 : 502,
error: timeout.didTimeout() ? 'indexer_graphql_timeout' : 'indexer_graphql_unreachable',
};
} finally {
timeout.clear();
}
}

function indexerTransportError(status: number, timedOut: boolean): string {
if (timedOut) return 'indexer_graphql_timeout';
if (status === 404) return 'indexer_graphql_unconfigured';
return 'indexer_graphql_failed';
}
Comment on lines +113 to +117

const PREVIEW_IMAGE_CONTENT_TYPES = new Set([
'image/png',
'image/jpeg',
Expand All @@ -63,6 +139,9 @@ export async function readIndexerPreviewAsset(assetId: string): Promise<
return { ok: false, status: 400, error: 'invalid_preview_asset_id' };
}

// Preview assets remain on the agent GraphQL surface until commonplace-api
// mounts topicPreviewAsset; Indexer object reads already use the data door.
const { callHarnessGraphql } = await import('@/lib/server/harness-graphql');
const result = await callHarnessGraphql(
Comment on lines +142 to 145
`
query ConsoleIndexerPreview($assetId: String!) {
Expand Down Expand Up @@ -96,20 +175,20 @@ export async function readIndexerObjects(options: {
readonly topicId?: string;
readonly includeCaptures?: boolean;
}): Promise<IndexerRead> {
const result = await callHarnessGraphql(INDEXER_OBJECTS_QUERY, {
const result = await executeConsumerGraphql(INDEXER_OBJECTS_QUERY, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the Indexer field before switching GraphQL schemas

This switches Indexer reads to the consumer schema, but a repo-wide search of this commit's apps/commonplace-api implementation finds no topicIndexerObjects field; the Query implementation beginning at apps/commonplace-api/src/schema/mod.rs:1808 does not declare it. Consequently commonplace-api returns a GraphQL unknown-field error for every Indexer object read, whereas the previous MCP schema owned this field.

AGENTS.md reference: AGENTS.md:L27-L27

Useful? React with 👍 / 👎.

topicId: options.topicId ?? null,
includeCaptures: options.includeCaptures ?? Boolean(options.topicId),
});
if (!result.ok) {
return {
ok: false,
status: result.status,
error: indexerError(result.error),
error: result.error,
};
}
return {
ok: true,
tenant: result.principal.tenant,
tenant: result.tenant,
objects: objectsFromPayload(result.data),
};
}
Expand Down
Loading
Loading