fix(config): reject reserved ingest connection ids

This commit is contained in:
Andrey Avtomonov 2026-05-13 18:36:12 +02:00
parent ca61f3e08e
commit 23dba892cd
9 changed files with 132 additions and 10 deletions

View file

@ -1,4 +1,5 @@
import { type Command, InvalidArgumentError, Option } from '@commander-js/extra-typings';
import { reservedKtxIngestConnectionIdMessage } from '@ktx/context/project';
import type { KtxCliCommandContext } from '../cli-program.js';
import { resolveCommandProjectDir } from '../cli-program.js';
import type { KtxSetupDatabaseDriver } from '../setup-databases.js';
@ -268,6 +269,10 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
}
const reservedMessage = reservedKtxIngestConnectionIdMessage(value);
if (reservedMessage) {
throw new InvalidArgumentError(reservedMessage);
}
return value;
})
.option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection')

View file

@ -1175,6 +1175,18 @@ describe('runKtxCli', () => {
);
});
it('rejects reserved setup database connection ids before dispatch', async () => {
const testIo = makeIo();
const setup = vi.fn(async () => 0);
await expect(
runKtxCli(['setup', '--new-database-connection-id', 'status', '--no-input'], testIo.io, { setup }),
).resolves.toBe(1);
expect(setup).not.toHaveBeenCalled();
expect(testIo.stderr()).toContain('"status" is reserved for ktx ingest status; choose a different connection id.');
});
it('dispatches setup source flags', async () => {
const setup = vi.fn(async () => 0);
const testIo = makeIo();

View file

@ -1717,6 +1717,26 @@ describe('setup databases step', () => {
expect(io.stderr()).toContain('Missing database connection id');
});
it('rejects reserved non-interactive database connection ids', async () => {
const io = makeIo();
const result = await runKtxSetupDatabasesStep(
{
projectDir: tempDir,
inputMode: 'disabled',
databaseDrivers: ['postgres'],
databaseConnectionId: 'replay',
databaseUrl: 'env:DATABASE_URL',
databaseSchemas: [],
skipDatabases: false,
},
io.io,
);
expect(result.status).toBe('failed');
expect(io.stderr()).toContain('"replay" is reserved for ktx ingest replay; choose a different connection id.');
});
it('leaves setup incomplete when primary sources are skipped', async () => {
const io = makeIo();

View file

@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { HistoricSqlDialect } from '@ktx/context/ingest';
import {
assertKtxConnectionIdIsNotReserved,
type KtxProjectConnectionConfig,
loadKtxProject,
markKtxSetupStateStepComplete,
@ -227,6 +228,13 @@ function unique(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}
function assertSafeDatabaseConnectionId(connectionId: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
throw new Error(`Unsafe connection id: ${connectionId}`);
}
assertKtxConnectionIdIsNotReserved(connectionId);
}
function historicSqlConfigRecord(connection: KtxProjectConnectionConfig | undefined): Record<string, unknown> | null {
const historicSql = connection?.historicSql;
return historicSql && typeof historicSql === 'object' && !Array.isArray(historicSql)
@ -1665,10 +1673,12 @@ async function chooseConnectionIdForDriver(input: {
prompts: KtxSetupDatabasesPromptAdapter;
}): Promise<{ kind: 'existing' | 'new'; connectionId: string } | 'back' | 'missing-input'> {
if (input.args.databaseConnectionId) {
assertSafeDatabaseConnectionId(input.args.databaseConnectionId);
return { kind: 'new', connectionId: input.args.databaseConnectionId };
}
if (input.args.inputMode === 'disabled') {
if (!input.args.databaseConnectionId) return 'missing-input';
assertSafeDatabaseConnectionId(input.args.databaseConnectionId);
return { kind: 'new', connectionId: input.args.databaseConnectionId };
}
@ -1684,6 +1694,7 @@ async function chooseConnectionIdForDriver(input: {
});
if (entered === undefined) return 'back';
const connectionId = entered.trim() || defaultId;
assertSafeDatabaseConnectionId(connectionId);
return connectionId ? { kind: 'new', connectionId } : 'missing-input';
}
@ -1708,6 +1719,7 @@ async function chooseConnectionIdForDriver(input: {
});
if (entered === undefined) continue;
const connectionId = entered.trim() || defaultId;
assertSafeDatabaseConnectionId(connectionId);
return connectionId ? { kind: 'new', connectionId } : 'missing-input';
}
}
@ -1780,12 +1792,18 @@ export async function runKtxSetupDatabasesStep(
for (const driver of drivers) {
const project = await loadKtxProject({ projectDir: args.projectDir });
const connectionChoice = await chooseConnectionIdForDriver({
driver,
connections: project.config.connections,
args,
prompts,
});
let connectionChoice: Awaited<ReturnType<typeof chooseConnectionIdForDriver>>;
try {
connectionChoice = await chooseConnectionIdForDriver({
driver,
connections: project.config.connections,
args,
prompts,
});
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return { status: 'failed', projectDir: args.projectDir };
}
if (connectionChoice === 'back') {
if (!canReturnToDriverSelection) return { status: 'back', projectDir: args.projectDir };
returnToDriverSelection = true;

View file

@ -254,6 +254,31 @@ describe('setup sources step', () => {
});
});
it('rejects reserved interactive source connection ids', async () => {
await addPrimarySource();
const io = makeIo();
const result = await runKtxSetupSourcesStep(
{
projectDir,
inputMode: 'auto',
runInitialSourceIngest: false,
skipSources: false,
},
io.io,
{
prompts: prompts({
multiselect: [['notion']],
text: ['status', 'env:NOTION_TOKEN'],
select: ['env', 'all_accessible'],
}),
},
);
expect(result.status).toBe('failed');
expect(io.stderr()).toContain('"status" is reserved for ktx ingest status; choose a different connection id.');
});
it('uses selected Notion roots when root page ids are provided even if crawl mode says all accessible', async () => {
await addPrimarySource();
const validateNotion = vi.fn(async () => ({ ok: true as const, detail: 'roots=1' }));

View file

@ -19,6 +19,7 @@ import {
testRepoConnection,
} from '@ktx/context/ingest';
import {
assertKtxConnectionIdIsNotReserved,
type KtxProjectConfig,
type KtxProjectConnectionConfig,
loadKtxProject,
@ -201,6 +202,7 @@ function assertSafeConnectionId(connectionId: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
throw new Error(`Unsafe connection id: ${connectionId}`);
}
assertKtxConnectionIdIsNotReserved(connectionId);
}
function credentialRef(value: string | undefined, label: string): string {

View file

@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './config.js';
describe('KTX project config', () => {
it.each(['status', 'replay', 'run', 'watch'])('rejects reserved ingest connection id "%s"', (connectionId) => {
expect(() =>
parseKtxProjectConfig(`
project: reserved-test
connections:
${connectionId}:
driver: postgres
`),
).toThrow(`"${connectionId}" is reserved for ktx ingest ${connectionId}`);
});
it('builds the default standalone project config', () => {
expect(buildDefaultKtxProjectConfig('warehouse')).toEqual({
project: 'warehouse',

View file

@ -112,6 +112,25 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
const RESERVED_INGEST_CONNECTION_IDS = new Map([
['status', 'ktx ingest status'],
['replay', 'ktx ingest replay'],
['run', 'ktx ingest run'],
['watch', 'ktx ingest watch'],
]);
export function reservedKtxIngestConnectionIdMessage(connectionId: string): string | null {
const command = RESERVED_INGEST_CONNECTION_IDS.get(connectionId);
return command ? `"${connectionId}" is reserved for ${command}; choose a different connection id.` : null;
}
export function assertKtxConnectionIdIsNotReserved(connectionId: string): void {
const message = reservedKtxIngestConnectionIdMessage(connectionId);
if (message) {
throw new Error(message);
}
}
function stringArray(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) {
return fallback;
@ -485,6 +504,12 @@ export function parseKtxProjectConfig(raw: string): KtxProjectConfig {
...(isRecord(scanEnrichment.embeddings) ? { embeddings: scanEmbeddings } : {}),
};
const parsedScanRelationships = parseScanRelationshipConfig(scanRelationships, defaults.scan.relationships);
const parsedConnections = isRecord(parsed.connections)
? (parsed.connections as Record<string, KtxProjectConnectionConfig>)
: defaults.connections;
for (const connectionId of Object.keys(parsedConnections)) {
assertKtxConnectionIdIsNotReserved(connectionId);
}
return {
project: project.trim(),
@ -495,9 +520,7 @@ export function parseKtxProjectConfig(raw: string): KtxProjectConfig {
},
}
: {}),
connections: isRecord(parsed.connections)
? (parsed.connections as Record<string, KtxProjectConnectionConfig>)
: defaults.connections,
connections: parsedConnections,
storage: {
state: storage.state === 'sqlite' ? 'sqlite' : defaults.storage.state,
search: storage.search === 'sqlite-fts5' ? 'sqlite-fts5' : defaults.storage.search,

View file

@ -6,7 +6,13 @@ export type {
KtxSearchBackend,
KtxStorageState,
} from './config.js';
export { buildDefaultKtxProjectConfig, parseKtxProjectConfig, serializeKtxProjectConfig } from './config.js';
export {
assertKtxConnectionIdIsNotReserved,
buildDefaultKtxProjectConfig,
parseKtxProjectConfig,
reservedKtxIngestConnectionIdMessage,
serializeKtxProjectConfig,
} from './config.js';
export type { LocalGitFileStoreDeps } from './local-git-file-store.js';
export { LocalGitFileStore } from './local-git-file-store.js';
export { ktxLocalStateDbPath } from './local-state-db.js';