diff --git a/apps/web/src/backend/api/routes.ts b/apps/web/src/backend/api/routes.ts index 64367d7c..f859c3e8 100644 --- a/apps/web/src/backend/api/routes.ts +++ b/apps/web/src/backend/api/routes.ts @@ -1043,6 +1043,29 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt const userId = (req as AuthedRequest).userId!; const databaseId = String(req.params.id); + // History is keyed by database identity; the execute connection must be + // that same database or we would apply reverse DDL to the wrong target. + const identityMatch = await lokeeWeave.matchDatabaseIdentity(userId, databaseId, { + dialect, + host: option.host ?? null, + port: option.port ?? null, + database: option.database ?? null, + schema: schema ?? null, + }); + if (identityMatch === 'not_found') { + res.status(404).json({ error: 'Database not found' }); + return; + } + if (identityMatch === 'mismatch') { + res.status(409).json({ + ok: false, + error: + 'The selected connection does not match this schema history. Choose the credential for the same database before reverting.', + code: 'connection_mismatch', + }); + return; + } + const plan = await lokeeWeave.planRevert(userId, databaseId, toVersionId, dialect, schema); if (!plan) { res.status(404).json({ error: 'Version not found' }); diff --git a/apps/web/src/backend/modules/lokee-weave.module.test.ts b/apps/web/src/backend/modules/lokee-weave.module.test.ts index 6ca08d56..7d640fc8 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.test.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.test.ts @@ -518,6 +518,32 @@ describe('inspectObject', () => { }); }); +describe('matchDatabaseIdentity', () => { + it('accepts the same identity the history was captured under', async () => { + const { weave } = await freshStore(); + const captured = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + await expect( + weave.matchDatabaseIdentity(USER, captured.databaseId, IDENTITY) + ).resolves.toBe('ok'); + }); + + it('rejects a different database so revert cannot target the wrong connection', async () => { + const { weave } = await freshStore(); + const captured = await weave.capture(USER, { ...IDENTITY, tables: [CUSTOMER], source: 'manual' }); + await expect( + weave.matchDatabaseIdentity(USER, captured.databaseId, { + ...IDENTITY, + database: 'other_shop', + }) + ).resolves.toBe('mismatch'); + }); + + it('returns not_found for an unknown or foreign history id', async () => { + const { weave } = await freshStore(); + await expect(weave.matchDatabaseIdentity(USER, 'missing', IDENTITY)).resolves.toBe('not_found'); + }); +}); + describe('planRevert', () => { it('classifies dropping a later column as lossy and emits DROP COLUMN', async () => { const { weave } = await freshStore(); diff --git a/apps/web/src/backend/modules/lokee-weave.module.ts b/apps/web/src/backend/modules/lokee-weave.module.ts index c954ade2..ea540d45 100644 --- a/apps/web/src/backend/modules/lokee-weave.module.ts +++ b/apps/web/src/backend/modules/lokee-weave.module.ts @@ -660,6 +660,25 @@ export class LokeeWeaveStore { return Boolean(row?.id); } + /** + * Revert plans from `databaseId` history but executes on a caller-supplied + * connection. Refuse when that connection’s identity is not the same row — + * otherwise DDL from one history can run against another database. + */ + async matchDatabaseIdentity( + userId: string, + databaseId: string, + input: DatabaseIdentityInput + ): Promise<'ok' | 'not_found' | 'mismatch'> { + const store = await this.store(); + const row = await store.get<{ fingerprint: string }>( + 'SELECT fingerprint FROM lokee_databases WHERE id = ? AND user_id = ?', + [databaseId, userId] + ); + if (!row) return 'not_found'; + return row.fingerprint === databaseIdentity(input, sha256) ? 'ok' : 'mismatch'; + } + async listVersions(userId: string, databaseId: string, limit = 100): Promise { const store = await this.store(); if (!(await this.assertOwned(store, userId, databaseId))) return []; diff --git a/apps/web/src/frontend/api/lokeeApi.ts b/apps/web/src/frontend/api/lokeeApi.ts index 2496e04f..99372e64 100644 --- a/apps/web/src/frontend/api/lokeeApi.ts +++ b/apps/web/src/frontend/api/lokeeApi.ts @@ -205,11 +205,11 @@ export interface LokeeRevertPlan { } export class LokeeRevertError extends Error { - readonly code: 'blocked' | 'confirm_lossy' | 'failed'; + readonly code: 'blocked' | 'confirm_lossy' | 'connection_mismatch' | 'failed'; readonly plan?: LokeeRevertPlan; constructor( message: string, - code: 'blocked' | 'confirm_lossy' | 'failed', + code: 'blocked' | 'confirm_lossy' | 'connection_mismatch' | 'failed', plan?: LokeeRevertPlan ) { super(message); @@ -260,7 +260,12 @@ export async function executeLokeeRevert( LokeeRevertPlan & { ok?: boolean; error?: string; code?: string; capture?: CaptureResult } >(res); if (res.ok) return { ...data, ok: true as const }; - const code = data.code === 'blocked' || data.code === 'confirm_lossy' ? data.code : 'failed'; + const code = + data.code === 'blocked' || + data.code === 'confirm_lossy' || + data.code === 'connection_mismatch' + ? data.code + : 'failed'; throw new LokeeRevertError( data.error || res.statusText || 'Revert failed', code,