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
42 changes: 40 additions & 2 deletions docs/deploy/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ <h2>Docker Compose (full stack)</h2>
<span class="key">OPENAI_API_KEY:</span> <span class="value">${OPENAI_API_KEY}</span>
<span class="key">GITHUB_TOKEN:</span> <span class="value">${GITHUB_TOKEN:-}</span>
<span class="key">GITHUB_WEBHOOK_SECRET:</span> <span class="value">${GITHUB_WEBHOOK_SECRET:-}</span>
<span class="key">MCP_JWT_SECRET:</span> <span class="value">${MCP_JWT_SECRET}</span>
<span class="key">PATHFINDER_CONFIG:</span> <span class="value">/app/pathfinder.yaml</span>
<span class="key">WORKSPACE_DIR:</span> <span class="value">/data/workspaces</span>
<span class="key">PORT:</span> <span class="value">${PORT:-3001}</span>
Expand Down Expand Up @@ -398,7 +399,7 @@ <h2>Environment Variables</h2>
</thead>
<tbody>
<tr><td><code>DATABASE_URL</code></td><td>For search tools</td><td>-</td><td>PostgreSQL connection string (with pgvector)</td></tr>
<tr><td><code>MCP_JWT_SECRET</code></td><td><strong>Required in production</strong></td><td>-</td><td>HMAC secret for signing OAuth access/refresh tokens. Generate with <code>openssl rand -hex 32</code>. Rotating this invalidates all issued tokens — clients will re-authenticate transparently. In development mode (<code>NODE_ENV=development</code>) a random secret is generated per process and logged as a warning.</td></tr>
<tr><td><code>MCP_JWT_SECRET</code></td><td><strong>Required in production</strong></td><td>-</td><td>HMAC secret for signing OAuth access/refresh tokens. Generate with <code>openssl rand -hex 32</code>. Rotating this invalidates all issued tokens — clients will re-authenticate transparently. In any non-production environment (any <code>NODE_ENV</code> other than <code>production</code>) a random secret is generated per process and logged as a warning.</td></tr>
<tr><td><code>OPENAI_API_KEY</code></td><td>When embedding.provider is "openai" (default)</td><td>-</td><td>OpenAI API key for computing embeddings. Not needed for ollama or local providers.</td></tr>
<tr><td><code>GITHUB_TOKEN</code></td><td>For private repos</td><td>-</td><td>GitHub PAT for cloning private repositories</td></tr>
<tr><td><code>GITHUB_WEBHOOK_SECRET</code></td><td>For webhooks</td><td>-</td><td>Secret for validating GitHub webhook payloads</td></tr>
Expand All @@ -413,7 +414,7 @@ <h2>Environment Variables</h2>
<tr><td><code>NODE_ENV</code></td><td>No</td><td><code>development</code></td><td>Set to <code>production</code> for deployed instances</td></tr>
<tr><td><code>LOG_LEVEL</code></td><td>No</td><td><code>info</code></td><td>Logging verbosity (debug, info, warn, error)</td></tr>
<tr><td><code>CLONE_DIR</code></td><td>No</td><td><code>/tmp/mcp-repos</code></td><td>Directory for git repo clones</td></tr>
<tr><td><code>ANALYTICS_TOKEN</code></td><td>When analytics enabled</td><td>-</td><td>Bearer token for authenticating <code>/api/analytics/*</code> endpoints</td></tr>
<tr><td><code>ANALYTICS_TOKEN</code></td><td>For privileged surfaces</td><td>-</td><td>Shared admin-access bearer token for all privileged surfaces — analytics (<code>/api/analytics/*</code>), Atlas ratification (<code>/api/atlas/*</code>), and admin ops (<code>/admin/*</code>). See <a href="#admin-control-surface">Admin control surface</a>.</td></tr>
</tbody>
</table>

Expand Down Expand Up @@ -448,6 +449,43 @@ <h3 id="webhook-urls">Webhook URLs</h3>
<p><strong>Slack:</strong> Set your Slack app's Event Subscriptions Request URL to <code>https://your-domain/webhooks/slack</code>.</p>
<p><strong>Discord:</strong> Set your Discord application's Interactions Endpoint URL to <code>https://your-domain/webhooks/discord</code>.</p>

<h2 id="admin-control-surface">Admin control surface</h2>

<p>Pathfinder exposes an authenticated control plane for operational tasks that would otherwise require database surgery and a redeploy — forcing a reindex, inspecting index state, and so on.</p>

<h3 id="admin-auth">Authentication</h3>
<p>All privileged surfaces — analytics (<code>/api/analytics/*</code>), Atlas ratification (<code>/api/atlas/*</code>), and admin ops (<code>/admin/*</code>) — share <strong>one</strong> admin-access bearer token: the <code>ANALYTICS_TOKEN</code> environment variable. Authenticate every request with an <code>Authorization: Bearer $ANALYTICS_TOKEN</code> header.</p>
<ul>
<li><strong>401 Unauthorized</strong> — the token is missing or does not match.</li>
<li><strong>503 Service Unavailable</strong> — no token is configured. These surfaces fail closed: with no <code>ANALYTICS_TOKEN</code> set, they reject every request rather than running unauthenticated.</li>
</ul>

<h3 id="admin-reindex">Force a reindex — <code>POST /admin/reindex</code></h3>
<p>Queues an indexing job and returns <strong>202 Accepted</strong>. The body selects the scope:</p>
<ul>
<li><code>{ "scope": "full" }</code> — reindex every configured source.</li>
<li><code>{ "scope": "source", "source": "&lt;configured-source-name&gt;" }</code> — reindex a single named source.</li>
<li><code>{ "scope": "repo", "repo": "&lt;configured-repo-url&gt;" }</code> — incrementally reindex a single git-backed source by repo URL.</li>
</ul>
<p>An unknown source name or repo URL returns <strong>400 Bad Request</strong> so a typo fails loud instead of silently no-op-ing.</p>

<div class="code-block"><span class="cmd">$</span> curl -X POST https://your-domain/admin/reindex \
-H <span class="value">"Authorization: Bearer $ANALYTICS_TOKEN"</span> \
-H <span class="value">"Content-Type: application/json"</span> \
-d <span class="value">'{"scope":"source","source":"my-docs"}'</span></div>

<h3 id="admin-index-stats">Inspect index state — <code>GET /admin/index-stats</code></h3>
<p>Returns <strong>200 OK</strong> with current index statistics (a <code>POST /admin/index-stats</code> alias is also accepted):</p>
<div class="code-block"><span class="cmd">$</span> curl https://your-domain/admin/index-stats \
-H <span class="value">"Authorization: Bearer $ANALYTICS_TOKEN"</span></div>
<p>The response body has the shape:</p>
<div class="code-block">{
<span class="key">"total_chunks"</span>: <span class="value">1280</span>,
<span class="key">"by_source"</span>: { <span class="key">"my-docs"</span>: <span class="value">1280</span> },
<span class="key">"indexed_repos"</span>: [<span class="value">"https://github.com/acme/docs"</span>],
<span class="key">"sources"</span>: [ <span class="comment">/* per-source type, key, status, last_indexed, commit, error */</span> ]
}</div>

<h2>Volume Mounts</h2>

<p>What you mount depends on your source configuration:</p>
Expand Down
62 changes: 61 additions & 1 deletion src/__tests__/atlas-db.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
vi,
} from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { __setPoolForTesting, __resetPoolForTesting } from "../db/client.js";
import { generatePostSchemaMigration } from "../db/schema.js";
Expand All @@ -13,6 +21,7 @@ import {
rejectAtlasSeedEntry,
upsertAtlasCachePage,
upsertAtlasSeedCandidate,
__testing,
} from "../db/atlas.js";

const ATLAS_DDL_MARKER = "-- Atlas durable seed knowledge.";
Expand Down Expand Up @@ -457,3 +466,54 @@ describe("Atlas DB helpers", () => {
]);
});
});

describe("Atlas row-mapper robustness", () => {
it("throws a context-bearing error (not a bare SyntaxError) for a malformed JSON seed column", () => {
expect(() =>
__testing.mapSeedRow({
id: 42,
canonical_key: "runtime:why",
source_name: "atlas",
status: "approved",
title: "Runtime why",
content: "body",
provenance: "{not valid json",
evidence: "[]",
}),
).toThrowError(/provenance of seed row id=42 key=runtime:why/);
});

it("attributes a malformed cache JSON column to its row identity", () => {
expect(() =>
__testing.mapCacheRow({
id: 7,
page_key: "runtime/overview",
source_name: "atlas",
title: "Runtime overview",
content_hash: "hash-1",
stale: false,
generated_seed_ids: "[1, 2,",
provenance: "{}",
}),
).toThrowError(
/generated_seed_ids of cache row id=7 key=runtime\/overview/,
);
});

it("returns null and warns for an invalid timestamp instead of yielding Invalid Date", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = __testing.toDate("not-a-date", "approved_at of seed row 5");
expect(result).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("invalid timestamp"),
);
warnSpy.mockRestore();
});

it("passes through valid timestamps unchanged", () => {
const iso = "2026-01-01T00:00:00.000Z";
const result = __testing.toDate(iso);
expect(result).toBeInstanceOf(Date);
expect(result?.toISOString()).toBe(iso);
});
});
55 changes: 55 additions & 0 deletions src/__tests__/atlas-ratification-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { __setPoolForTesting, __resetPoolForTesting } from "../db/client.js";
import { generatePostSchemaMigration } from "../db/schema.js";
import {
approveAtlasSeedEntry,
listPendingAtlasSeedCandidates,
upsertAtlasSeedCandidate,
} from "../db/atlas.js";
import { AtlasDataProvider } from "../indexing/providers/atlas.js";
Expand Down Expand Up @@ -448,6 +449,60 @@ describe("Atlas ratification endpoints", () => {
expect(queueSourceReindex).toHaveBeenCalledWith("atlas");
});

it("reports reindexQueued:false (NOT 500) when the queue enqueue throws after a durable approval", async () => {
await upsertAtlasSeedCandidate({
canonicalKey: "runtime:approve-queue-throws",
sourceName: "atlas",
title: "Approve while queue throws",
content: "Candidate approved while the reindex enqueue throws",
provenance: {},
evidence: [],
});
const queueSourceReindex = vi.fn(() => {
throw new Error("queue is on fire");
});
__setAtlasOrchestratorForTesting({
queueFullReindex: vi.fn(),
queueSourceReindex,
queueIncrementalReindex: vi.fn(),
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
server = await startServer();

const approved = await request(
server,
"POST",
"/api/atlas/candidates/approve",
{
headers: {
Authorization: "Bearer secret",
"X-Atlas-Actor": "reviewer@example.test",
},
body: { canonicalKey: "runtime:approve-queue-throws" },
},
);

// A reindex-enqueue hiccup must NOT report a committed approval as a failure.
expect(approved.status).toBe(200);
const body = JSON.parse(approved.body);
expect(body.reindexQueued).toBe(false);
expect(body.candidate).toMatchObject({
canonicalKey: "runtime:approve-queue-throws",
status: "approved",
});
expect(queueSourceReindex).toHaveBeenCalledWith("atlas");
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();

// The approval must be durably persisted — verified here by its absence
// from the pending list (the candidate no longer awaits review). The
// 409-on-re-approve behavior is covered by a separate test.
const pending = await listPendingAtlasSeedCandidates();
expect(pending.map((row) => row.canonicalKey)).not.toContain(
"runtime:approve-queue-throws",
);
});

it("returns 409 when approving a candidate that is missing or not pending", async () => {
server = await startServer();

Expand Down
80 changes: 59 additions & 21 deletions src/db/atlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,35 +120,59 @@ export type AtlasIndexableContent =
cachePage: AtlasCachePage;
};

function parseJsonObject(value: unknown): Record<string, unknown> {
// Parse a JSON string column with row-attributed context. A single malformed
// `provenance`/`evidence`/`generated_seed_ids` blob would otherwise throw a
// bare SyntaxError with no row identity and — because the list queries map
// every row — poison the WHOLE list query into an opaque 500 that hides all
// the valid rows. `ctx` names the column + offending row so the failure is
// actionable.
function parseJsonString<T>(value: string, ctx: string): T {
try {
return JSON.parse(value) as T;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse JSON for ${ctx}: ${detail}`);
}
}

function parseJsonObject(value: unknown, ctx: string): Record<string, unknown> {
if (value == null) return {};
if (typeof value === "string") {
return JSON.parse(value) as Record<string, unknown>;
return parseJsonString<Record<string, unknown>>(value, ctx);
}
return value as Record<string, unknown>;
}

function parseJsonArray(value: unknown): unknown[] {
function parseJsonArray(value: unknown, ctx: string): unknown[] {
if (value == null) return [];
if (typeof value === "string") {
return JSON.parse(value) as unknown[];
return parseJsonString<unknown[]>(value, ctx);
}
return value as unknown[];
}

function parseNumberArray(value: unknown): number[] {
return parseJsonArray(value).filter(
function parseNumberArray(value: unknown, ctx: string): number[] {
return parseJsonArray(value, ctx).filter(
(item): item is number => typeof item === "number",
);
}

function toDate(value: unknown): Date | null {
function toDate(value: unknown, ctx?: string): Date | null {
if (value == null) return null;
if (value instanceof Date) return value;
return new Date(value as string);
const d = new Date(value as string);
if (isNaN(d.getTime())) {
console.warn(
`[atlas] Ignoring invalid timestamp${ctx ? ` for ${ctx}` : ""}: ` +
`${JSON.stringify(value)}`,
);
return null;
}
return d;
}

function mapSeedRow(row: Record<string, unknown>): AtlasSeedEntry {
const ctx = `seed row id=${row.id} key=${String(row.canonical_key)}`;
return {
id: Number(row.id),
canonicalKey: row.canonical_key as string,
Expand All @@ -159,20 +183,21 @@ function mapSeedRow(row: Record<string, unknown>): AtlasSeedEntry {
status: row.status as AtlasSeedStatus,
title: row.title as string,
content: row.content as string,
provenance: parseJsonObject(row.provenance),
evidence: parseJsonArray(row.evidence),
provenance: parseJsonObject(row.provenance, `provenance of ${ctx}`),
evidence: parseJsonArray(row.evidence, `evidence of ${ctx}`),
approvedBy: (row.approved_by as string | null) ?? null,
approvedAt: toDate(row.approved_at),
approvedAt: toDate(row.approved_at, `approved_at of ${ctx}`),
rejectedBy: (row.rejected_by as string | null) ?? null,
rejectedAt: toDate(row.rejected_at),
rejectedAt: toDate(row.rejected_at, `rejected_at of ${ctx}`),
rejectionReason: (row.rejection_reason as string | null) ?? null,
createdAt: toDate(row.created_at) ?? new Date(0),
updatedAt: toDate(row.updated_at) ?? new Date(0),
createdAt: toDate(row.created_at, `created_at of ${ctx}`) ?? new Date(0),
updatedAt: toDate(row.updated_at, `updated_at of ${ctx}`) ?? new Date(0),
};
}

function mapCacheRow(row: Record<string, unknown>): AtlasCachePage {
const rawProvenance = parseJsonObject(row.provenance);
const ctx = `cache row id=${row.id} key=${String(row.page_key)}`;
const rawProvenance = parseJsonObject(row.provenance, `provenance of ${ctx}`);
const { [CACHE_CONTENT_KEY]: contentValue, ...provenance } = rawProvenance;
return {
id: Number(row.id),
Expand All @@ -183,13 +208,16 @@ function mapCacheRow(row: Record<string, unknown>): AtlasCachePage {
contentHash: row.content_hash as string,
stale: Boolean(row.stale),
staleReason: (row.stale_reason as string | null) ?? null,
generatedSeedIds: parseNumberArray(row.generated_seed_ids),
generatedSeedIds: parseNumberArray(
row.generated_seed_ids,
`generated_seed_ids of ${ctx}`,
),
provenance,
generatedAt: toDate(row.generated_at),
errorAt: toDate(row.error_at),
generatedAt: toDate(row.generated_at, `generated_at of ${ctx}`),
errorAt: toDate(row.error_at, `error_at of ${ctx}`),
errorMessage: (row.error_message as string | null) ?? null,
createdAt: toDate(row.created_at) ?? new Date(0),
updatedAt: toDate(row.updated_at) ?? new Date(0),
createdAt: toDate(row.created_at, `created_at of ${ctx}`) ?? new Date(0),
updatedAt: toDate(row.updated_at, `updated_at of ${ctx}`) ?? new Date(0),
};
}

Expand Down Expand Up @@ -772,10 +800,20 @@ export async function getAtlasStateToken(
seedResult.rows[0]?.state_token,
cacheResult.rows[0]?.state_token,
]
.map((value) => toDate(value))
.map((value) => toDate(value, "atlas state token"))
.filter((value): value is Date => value !== null);
if (values.length === 0) return null;
return new Date(
Math.max(...values.map((value) => value.getTime())),
).toISOString();
}

// Test-only exports of the otherwise-private row mappers and timestamp parser.
// These are pure functions; exporting them lets us unit-test the robustness
// paths (malformed JSON → context-bearing error, invalid timestamp → null)
// directly without contriving a backing store that can hold malformed columns.
export const __testing = {
mapSeedRow,
mapCacheRow,
toDate,
};
Loading