((resolve, reject) => first.close((error) => error ? reject(error) : resolve())); }
diff --git a/docs/api-capability-inventory.md b/docs/api-capability-inventory.md
index a8184c5..f99961d 100644
--- a/docs/api-capability-inventory.md
+++ b/docs/api-capability-inventory.md
@@ -1,6 +1,6 @@
# Backend API capability inventory
-The backend exposes no OpenAPI document. This inventory is based on the current Express routes, workflow schemas, agent implementations, and existing response types. The backend has no authentication middleware; the local demo login is not an access boundary.
+The backend exposes no OpenAPI document. This inventory is based on the current Express routes, workflow schemas, agent implementations, and response types. There is no general user authentication or tenant isolation: the local demo login is not an access boundary, and clients can spoof `x-orgmind-user-id`. Graph and assistant data must not be treated as private until identities are verified server-side. Graph-writing and administrative routes require `RAG_ADMIN_API_KEY` in addition to the identity header.
| Endpoint | Contract / behavior | Frontend use |
|---|---|---|
@@ -13,7 +13,8 @@ The backend exposes no OpenAPI document. This inventory is based on the current
| `GET /api/tickets?q=&limit=&offset=` | Paginated Jira `Ticket` and `Bug` graph records | Tickets page |
| `GET /api/conversations?q=&limit=&offset=` | Paginated Slack `Message` records and linked channels | Conversations page |
| `GET /api/proactive-insights?q=&limit=&offset=` | Paginated GitHub pull request, issue, and commit records | Proactive Insights source view (not a generated risk assessment) |
-| `GET /api/integrations/status` | Connector configuration, verified indexed counts, last sync outcome, and ten recent sanitized sync runs; it does not actively probe the upstream provider | Integrations and Sync & Data pages |
+| `GET /api/integrations/status` | Bounded fetch of up to one sample record per source, configuration, cumulative verified indexed counts, latest run outcome, and ten recent sanitized sync runs. Sample reachability and ingestion health are separate. | Integrations and Sync & Data pages |
+| `POST /api/integrations/:source/sync` | Validated sync for one source; requires `RAG_ADMIN_API_KEY`. The demo browser cannot start syncs. | Backend operator only |
| `GET /api/graph/neighborhood/:entityType/:entityId` | Direct neighbors and relationship direction, capped at 100 | Graph expansion; entity, ownership, decision, and impact views |
| `GET /api/search?q=` | Up to 50 graph records matching name, title, or email | Global search and assistant citations |
| `GET /api/employees[?departmentId&teamId&employmentStatus]` | Employee list/filter | People directory |
@@ -21,9 +22,9 @@ The backend exposes no OpenAPI document. This inventory is based on the current
| `GET /api/departments[/:departmentId]` | Department list and department/team/employee details | Department directory/details |
| `GET /api/projects[/:projectId]` | Project list/details with owners, people, tasks, PRs, and services | Project directory/details and assistant cards |
| `GET /api/product-progress/graph` | Product Progress graph in the standard graph response shape | Product Progress graph |
-| `POST /api/product-progress/tickets` | Validated ticket identification and graph persistence | Existing workflow capability; no automatic assistant write action |
-| `POST /api/product-progress/activities` | Validated activity processing and graph persistence | Existing workflow capability; no automatic assistant write action |
-| `POST /api/product-progress/workflow` | Routes ticket or feature update input | Existing workflow capability |
+| `POST /api/product-progress/tickets` | Validated ticket identification and graph persistence; requires `RAG_ADMIN_API_KEY` | Existing workflow capability; no automatic assistant write action |
+| `POST /api/product-progress/activities` | Validated activity processing and graph persistence; requires `RAG_ADMIN_API_KEY` | Existing workflow capability; no automatic assistant write action |
+| `POST /api/product-progress/workflow` | Routes ticket or feature update input; requires `RAG_ADMIN_API_KEY` | Existing workflow capability |
| `POST /api/assistant/query` | LangGraph retrieval and optional synthesis; validated structured answer with graph evidence and execution stages | Right-side assistant drawer and safe response renderer
| `GET /api/assistant/conversations` | Lists up to 50 conversations for the supplied workspace identity | Assistant conversation switcher
| `GET /api/assistant/conversations/:conversationId` | Returns persisted turns after checking conversation ownership | Restore a prior assistant conversation
@@ -33,9 +34,11 @@ The backend exposes no OpenAPI document. This inventory is based on the current
The Documents page reads `GET /api/documents`; the Tickets page reads `GET /api/tickets`. Both routes paginate and filter persisted source records, and neither calls upstream services. Conversations reads `GET /api/conversations`, and Proactive Insights reads `GET /api/proactive-insights`. The overview page uses `GET /api/dashboard` for the company, organization and product graphs, counts, and dashboard cards. Backend read failures are displayed in the relevant page. Browser-triggered sync is disabled because the frontend has no trusted operator authentication channel; sync requires a backend-side caller holding `RAG_ADMIN_API_KEY`.
+For an integration sync response, `fetched` counts records returned by the connector, `processed` counts successful processing outcomes, and `persisted`/`indexedThisRun` count processed source IDs that the post-run graph check confirms. `indexed` is the cumulative distinct verified source-record count. Failed and skipped records are reported separately. A prior graph record that is merely present does not count as persisted by a failed current processing outcome.
+
Confluence fetching uses the configured server-side Confluence REST API connection (`CONFLUENCE_BASE_URL`, `CONFLUENCE_EMAIL`, and `CONFLUENCE_API_TOKEN` or `CONFLUENCE_API_KEY`) when `MOCK_API_BASE_URL` is not set. Jira fetching uses the configured connector at `MOCK_API_BASE_URL/api/jira/issues` with `JIRA_API_KEY` when supplied; a direct Jira Cloud REST client is not implemented. The route catalog's `/api/confluence/*` and `/api/jira/*` entries describe connector/source capabilities and are not Express routes exposed by this backend. The supplied Jira response contains `rawSeedId`, which marks the current records as fixtures; the ingestion workflow rejects these records and the Tickets API intentionally does not show them. Configure a connector that returns genuine Jira records marked with verified provenance before expecting them in Tickets.
-The same ingestion flow now persists Slack messages as graph `Message` nodes with channel relationships and GitHub pull requests, issues, and commits as graph source records. The frontend maps Conversations to Slack `Message` records and Proactive Insights to GitHub records. The latter is a source-backed activity view; the backend does not currently calculate or persist risk insights from GitHub data. Source refreshes use the route catalog contract (`/api/slack/conversations.list` and `/api/slack/conversations.history`; `/api/github/pulls`, `/api/github/issues`, and `/api/github/commits`) through `MOCK_API_BASE_URL`, which defaults to `http://localhost:8787` for these connector APIs.
+The same ingestion flow persists Slack messages as graph `Message` nodes with channel relationships and GitHub pull requests, issues, and commits as graph source records. The frontend maps Conversations to Slack `Message` records and Proactive Insights to GitHub records. The latter is a source-backed activity view; the backend does not currently calculate or persist risk insights from GitHub data. Source refreshes use the route catalog contract (`/api/slack/conversations.list` and `/api/slack/conversations.history`; `/api/github/pulls`, `/api/github/issues`, and `/api/github/commits`) through the explicitly configured `MOCK_API_BASE_URL`; there is no implicit connector URL fallback.
Per-source credentials are backend-only: `CONFLUENCE_API_KEY` (or the existing `CONFLUENCE_API_TOKEN` for direct Atlassian access), `JIRA_API_KEY`, `SLACK_API_KEY`, and `GITHUB_API_KEY`. For the route-catalog connector, a non-URL source key is sent in `x-api-key` and Bearer authorization headers, but it does not establish record provenance. Generic route-catalog responses are classified as synthetic even if the response asserts otherwise. Only the direct Confluence REST client marks records verified, and it excludes explicit seed markers. Graph reads are served from FalkorDB, so page navigation does not refetch upstream APIs.
@@ -59,7 +62,7 @@ These response shapes are useful connector contracts. The stats and source cards
The ingestion endpoint accepts `confluence`, `jira`, `github`, or `slack`, with an optional limit from 1 to 100. Confluence uses `CONFLUENCE_BASE_URL`, `CONFLUENCE_EMAIL`, and `CONFLUENCE_API_TOKEN` on the backend when no mock source URL is configured; pages are stored as verified `Document` nodes. Jira records route through ticket identification. Some GitHub and Slack records route through feature-update processing. Other sources use the backend `MOCK_API_BASE_URL`. Credentials are not accepted in browser requests. The endpoint returns counts and outcomes, not source content. Authorized runs are summarized in `IntegrationSyncRun` graph nodes and exposed through the integration status response. Synthetic records are counted as fetched, rejected before processing, and never written.
-In the local backend environment inspected on 2026-10-18, Confluence has no configured credentials or base URL. Jira, GitHub, and Slack use URL-valued `*_API_KEY` entries that point to the same route-catalog tunnel, not provider API tokens. The tunnel has been probed and returns seed-marked fixture data. Local authorized sync attempts reject those records and preserve zero verified writes. Native Jira, GitHub, and Slack provider REST clients are not part of this backend; setting a secret token alone does not turn the route-catalog connector into an upstream integration. Configure a trusted live connector with provider credentials and verifiable upstream records before expecting nonzero live source counts.
+Local runtime status queried on 2026-10-18 reported Confluence as a live provider with a completed sync (11 fetched, processed, persisted, and indexed; 0 failed), and Jira, GitHub, and Slack as mock sources. Their sample checks were reachable, while their latest syncs were partial: Jira 129 fetched / 109 processed / 109 persisted-reported / 20 failed / 109 cumulative indexed; GitHub 444 / 405 / 211 / 32 / 211; Slack 217 / 112 / 217 / 105 / 217. The stored Jira/GitHub/Slack persisted values predate the corrected run aggregation and may include IDs already indexed before those attempts; they must not be interpreted as current-run success totals. No new sync was run during this audit, so corrected persisted-this-run values remain unknown until the next authorized sync. Jira, GitHub, and Slack use the seeded mock API, not native provider connections. These counts are a point-in-time observation; the persistent latest-run record changes only after an authorized sync.
The assistant query endpoint `POST /api/assistant/query` runs a LangGraph workflow: validate, classify, retrieve graph entities and direct neighbors, optionally synthesize with the configured model, and validate a structured response. With model configuration disabled or unavailable, it returns deterministic summaries, verified graph citations, uncertainty, and execution stage statuses. It supports company overview, entity lookup, ownership lookup, decision investigation, impact traversal, graph query, and general explanation. Impact output explicitly reports direct graph traversal and does not claim a simulation. The response renderer uses an allowlist of predefined React views, and citation links resolve to graph-backed entities or their indexed source URL.
diff --git a/frontend/README.md b/frontend/README.md
index 29fb9dd..ce33c23 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -29,7 +29,7 @@ npm start
## Configuration
-Set `NEXT_PUBLIC_API_BASE_URL` in `.env.local` to the browser-accessible backend URL. The default is `http://localhost:8080`.
+Set `NEXT_PUBLIC_API_BASE_URL` in `.env.local` to the browser-accessible backend URL. Development falls back to `http://localhost:8080`; production builds fail requests with a clear configuration error when the variable is missing instead of calling the visitor's localhost.
The backend CORS allowlist includes `https://orgingraphs-ysq7.vercel.app`. Set `FRONTEND_ORIGINS` in the backend environment to a comma-separated list for local development or any additional deployment origins.
@@ -39,6 +39,6 @@ The backend CORS allowlist includes `https://orgingraphs-ysq7.vercel.app`. Set `
- The dashboard and directories use records returned by the backend. Initial graph layout is deterministic and limited to the first 24 employees; select and expand nodes to progressively load real neighborhoods.
- Search uses `GET /api/search` and supports Ctrl+K / Cmd+K. Result types without a dedicated detail endpoint open in the graph.
- The assistant builds allowlisted entity, ownership, decision evidence, direct graph impact, and graph query cards from search, entity detail, and neighborhood APIs. Citations link to indexed graph records. This backend does not expose a conversational answer endpoint; the UI does not claim that a LangGraph ingestion run is an assistant answer or an impact simulation.
-- Documents, Tickets, Conversations, and Proactive Insights display only verified records read from the backend graph. `/api/integrations/status` reports verified record counts and the last ten authorized sync outcomes; it does not actively probe provider connectivity. Sync requires a backend operator with `RAG_ADMIN_API_KEY` and is not exposed to the demo browser session. The configured Jira connector returns fixture `rawSeedId` values, so those issues are deliberately excluded from Tickets until a real verified Jira connector is configured.
+- Documents, Tickets, Conversations, and Proactive Insights display only verified records read from the backend graph. `/api/integrations/status` reports verified record counts and the last ten recorded sync outcomes; connectivity checks are separate. Sync requires a backend operator with `RAG_ADMIN_API_KEY` and is not exposed to the demo browser session. The configured Jira connector returns fixture `rawSeedId` values, so those issues are deliberately excluded from Tickets until a real verified Jira connector is configured.
- The assistant drawer calls `POST /api/assistant/query` and renders validated LangGraph response types with source citations, related entities, direct graph relationships, uncertainty, and execution stages. When the default model is disabled or unavailable, the backend provides a deterministic evidence-based response.
- Neighborhood relationships are labeled with their actual edge direction. Other relationships are displayed only when returned by the API or derivable from its list/detail fields.
diff --git a/frontend/components/assistant/AgentResponseRenderer.tsx b/frontend/components/assistant/AgentResponseRenderer.tsx
index 04a3e6b..b14776c 100644
--- a/frontend/components/assistant/AgentResponseRenderer.tsx
+++ b/frontend/components/assistant/AgentResponseRenderer.tsx
@@ -485,25 +485,6 @@ function isRagResponse(payload: unknown): payload is OrganizationRagResponse | R
);
}
-function TypedRagAnswer({ text, citationCount, onCitationClick }: { text: string; citationCount: number; onCitationClick: (number: number) => void }) {
- const [visibleText, setVisibleText] = useState("");
- useEffect(() => {
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
- setVisibleText(text);
- return;
- }
- const characters = Array.from(text);
- let visible = 0;
- const timer = window.setInterval(() => {
- visible = Math.min(characters.length, visible + 3);
- setVisibleText(characters.slice(0, visible).join(""));
- if (visible >= characters.length) window.clearInterval(timer);
- }, 14);
- return () => window.clearInterval(timer);
- }, [text]);
- return <>{visibleText.length < text.length && }>;
-}
-
function safeExternalSourceUrl(value?: string): string | undefined {
if (!value) return undefined;
try {
@@ -552,7 +533,7 @@ export function AgentResponseRenderer({
Nora · OrgMind
- Math.max(max, ...("citationIds" in source && source.citationIds?.length ? source.citationIds.map((id) => Number(id.slice(1))) : [index + 1])), 0)} onCitationClick={(number) => focusCitationSource(messageId, number)} />
+ Math.max(max, ...("citationIds" in source && source.citationIds?.length ? source.citationIds.map((id) => Number(id.slice(1))) : [index + 1])), 0)} onCitationClick={(number) => focusCitationSource(messageId, number)} />
{!hasEvidence && (
No relevant evidence was found in the indexed company sources.
)}
diff --git a/frontend/components/sources/DemoSyncDashboard.tsx b/frontend/components/sources/DemoSyncDashboard.tsx
index a61f50a..c9099d6 100644
--- a/frontend/components/sources/DemoSyncDashboard.tsx
+++ b/frontend/components/sources/DemoSyncDashboard.tsx
@@ -143,6 +143,7 @@ export function DemoSyncDashboard() {
Latest attempt: {lastAttempt ? timestamp(lastAttempt) : "Not recorded"}
Connectivity: {status}
Sync health: {syncLabel}
+ {!summary.browserSyncAvailable && Syncs require a backend operator credential and cannot be started from this demo workspace.
}
Run stages: {showCount(result?.fetched ?? summary.fetchedRecords)} fetched · {showCount(result?.processed ?? summary.processedRecords)} processed · {showCount(result?.persisted ?? summary.persistedRecords)} persisted · {showCount(result?.indexedThisRun ?? summary.indexedThisRun)} indexed this run · {showCount(result?.failed ?? summary.failedRecords)} failed · {showCount(result?.skipped ?? summary.skippedRecords)} skipped
Cumulative distinct indexed: {summary.verifiedIndexedRecordCount ?? "Unavailable"}
{summary.failureCategory && Failure category: {summary.failureCategory}
}
@@ -152,7 +153,7 @@ export function DemoSyncDashboard() {
-
+
Open records
;
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 910c71f..4a9022b 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -23,7 +23,11 @@ import type {
import { z } from "zod";
import { DEFAULT_DEMO_EMAIL, getDemoSession } from "@/lib/demo-auth";
-const baseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080").replace(/\/$/, "");
+const configuredBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL?.trim();
+if (process.env.NODE_ENV === "production" && !configuredBaseUrl) {
+ throw new Error("NEXT_PUBLIC_API_BASE_URL must be configured for production builds.");
+}
+const baseUrl = (configuredBaseUrl || (process.env.NODE_ENV !== "production" ? "http://localhost:8080" : "")).replace(/\/$/, "");
const ragQueryResponseSchema = z.object({
answer: z.string(),
@@ -52,6 +56,7 @@ export class ApiError extends Error {
}
async function request(path: string, options: RequestInit = {}, timeoutMs = 12_000): Promise {
+ if (!baseUrl) throw new ApiError("The backend API URL is not configured for this deployment.", null);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const signal = options.signal
diff --git a/frontend/lib/source-data.ts b/frontend/lib/source-data.ts
index 24febd6..eb5daee 100644
--- a/frontend/lib/source-data.ts
+++ b/frontend/lib/source-data.ts
@@ -13,6 +13,7 @@ export type SourceSummary = {
readinessStatus: "available" | "unavailable" | "unknown";
lastSyncTime: string | null;
syncState: "idle" | "running" | "completed" | "partial" | "failed" | "unknown";
+ browserSyncAvailable: boolean;
connectionState: string;
fetchedRecords: number | null;
processedRecords: number | null;
@@ -83,17 +84,17 @@ export function demoSourceSummary(id: "jira" | "slack", currentlyDisplayedRecord
id, displayName: source.displayName, dataMode: "demo",
totalAvailableRecords: DEMO_SOURCE_TOTALS[id], currentlyDisplayedRecords,
verifiedIndexedRecordCount: null, readinessStatus: "available",
- lastSyncTime: null, lastAttemptTime: null, syncState, connectionState: "Demo data", fetchedRecords: null, processedRecords: null, persistedRecords: null, indexedThisRun: null, failedRecords: null, skippedRecords: null, pendingRecords: null,
+ lastSyncTime: null, lastAttemptTime: null, syncState, browserSyncAvailable: false, connectionState: "Demo data", fetchedRecords: null, processedRecords: null, persistedRecords: null, indexedThisRun: null, failedRecords: null, skippedRecords: null, pendingRecords: null,
};
}
-export function liveSourceSummary(id: "confluence" | "jira" | "slack" | "github", count: number | null, error?: string, currentlyDisplayedRecords: number | null = null, details: Partial> = {}): SourceSummary {
+export function liveSourceSummary(id: "confluence" | "jira" | "slack" | "github", count: number | null, error?: string, currentlyDisplayedRecords: number | null = null, details: Partial> = {}): SourceSummary {
const source = SOURCE_CATALOG.find((item) => item.id === id)!;
return {
id, displayName: source.displayName, dataMode: source.dataMode,
totalAvailableRecords: count, currentlyDisplayedRecords,
verifiedIndexedRecordCount: count, readinessStatus: error ? "unavailable" : count === null ? "unknown" : "available",
- lastSyncTime: null, lastAttemptTime: null, syncState: "unknown", connectionState: "Checking", fetchedRecords: null, processedRecords: null, persistedRecords: null, indexedThisRun: null, failedRecords: null, skippedRecords: null, pendingRecords: null, ...details, ...(error ? { error } : {}),
+ lastSyncTime: null, lastAttemptTime: null, syncState: "unknown", browserSyncAvailable: false, connectionState: "Checking", fetchedRecords: null, processedRecords: null, persistedRecords: null, indexedThisRun: null, failedRecords: null, skippedRecords: null, pendingRecords: null, ...details, ...(error ? { error } : {}),
};
}
@@ -102,7 +103,7 @@ export function peopleSourceSummary(count: number | null): SourceSummary {
id: "people", displayName: "People Directory", dataMode: "live",
totalAvailableRecords: count, currentlyDisplayedRecords: null,
verifiedIndexedRecordCount: null, readinessStatus: count === null ? "unknown" : "available",
- lastSyncTime: null, lastAttemptTime: null, syncState: "unknown", connectionState: "Unknown", fetchedRecords: null, processedRecords: null, persistedRecords: null, indexedThisRun: null, failedRecords: null, skippedRecords: null, pendingRecords: null,
+ lastSyncTime: null, lastAttemptTime: null, syncState: "unknown", browserSyncAvailable: false, connectionState: "Unknown", fetchedRecords: null, processedRecords: null, persistedRecords: null, indexedThisRun: null, failedRecords: null, skippedRecords: null, pendingRecords: null,
};
}
@@ -155,6 +156,7 @@ export function integrationSummaries(response: IntegrationStatusResponse): Recor
lastAttemptTime: attemptedAt,
lastSyncTime: lastSuccessfulSync,
syncState,
+ browserSyncAvailable: source.browserSyncAvailable === true,
failureCategory: source.failureCategory,
endpoint: source.endpoint,
indexedError: source.indexedError,
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 12b0b0f..a84557e 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -174,6 +174,7 @@ export type IntegrationSourceStatus = {
sourceMode?: "live" | "mock";
connectionState: "Connected" | "Not configured" | "Authentication failed" | "Permission missing" | "Syncing" | "Sync failed" | string;
syncState: "idle" | "running" | "completed" | "partial" | "failed" | "empty";
+ browserSyncAvailable?: boolean;
fetched: number | null;
processed: number | null;
persisted?: number | null;