From 92026c78d1027624ee23618965ce1c34f0eb0976 Mon Sep 17 00:00:00 2001 From: tilyupo Date: Fri, 2 Oct 2026 18:17:52 +0300 Subject: [PATCH] Remove instance backup and restore feature --- .env.example | 3 - README.md | 7 - docs/instance-transfer.md | 45 --- next.config.ts | 2 - src/app/api/instance-transfer/route.spec.ts | 73 ---- src/app/api/instance-transfer/route.ts | 90 ----- src/components/settings-context.tsx | 1 - src/components/settings-dialog.tsx | 27 +- .../instance-transfer-settings.spec.tsx | 72 ---- .../settings/instance-transfer-settings.tsx | 129 ------ src/contexts/app-context.tsx | 10 +- src/engine/core/instance-transfer.ts | 193 --------- .../use-cases/get-app-layout-data-use-case.ts | 4 +- src/environment.ts | 5 - tests/instance-transfer.spec.ts | 374 ------------------ 15 files changed, 4 insertions(+), 1031 deletions(-) delete mode 100644 docs/instance-transfer.md delete mode 100644 src/app/api/instance-transfer/route.spec.ts delete mode 100644 src/app/api/instance-transfer/route.ts delete mode 100644 src/components/settings/instance-transfer-settings.spec.tsx delete mode 100644 src/components/settings/instance-transfer-settings.tsx delete mode 100644 src/engine/core/instance-transfer.ts delete mode 100644 tests/instance-transfer.spec.ts diff --git a/.env.example b/.env.example index 718fff55..41f92654 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,3 @@ PORT=3000 SUPERUSER_API_KEY=dev-superuser-key REPLANE_ADMIN_API_BASE_URL=http://localhost:3000 REPLANE_EDGE_API_BASE_URL=http://localhost3000 - -# Enables Settings > Instance > Backup & restore. Use a long random operator-only token. -# INSTANCE_TRANSFER_TOKEN= diff --git a/README.md b/README.md index ca722136..5d4a31b8 100644 --- a/README.md +++ b/README.md @@ -317,13 +317,6 @@ For detailed security guidelines and to report vulnerabilities, see [SECURITY.md Have questions or want to discuss Replane? Join the conversation in [GitHub Discussions](https://github.com/orgs/replane-dev/discussions). -### Instance backup and restore - -To move all projects, configs, users, memberships, and history to another instance, -set `INSTANCE_TRANSFER_TOKEN` and use **Settings → Instance → Backup & restore**. -See the [instance migration guide](docs/instance-transfer.md) for the export/import -workflow, credential handling, and required restart steps. - ## License MIT diff --git a/docs/instance-transfer.md b/docs/instance-transfer.md deleted file mode 100644 index a503f2da..00000000 --- a/docs/instance-transfer.md +++ /dev/null @@ -1,45 +0,0 @@ -# Move a Replane instance - -Set `INSTANCE_TRANSFER_TOKEN` to a long random secret on each instance to enable -**Settings → Instance → Backup & restore**. Enter that instance's token to export -or import. The token is an instance-wide operator credential, independent of -workspace roles; only share it with operators. Unset it after the migration. - -1. Run the same Replane version on source and destination. Configure the destination's - database, authentication providers, email, and other environment variables separately. - Use a fresh destination `SECRET_KEY` so sessions from the previous destination or - source cannot authenticate as a different imported user with the same numeric ID. -2. Stop writes to the source for the final export. Download the JSON backup from settings. - Protect this file: it contains password hashes, OAuth credentials, API key hashes, - and private configuration. It is not encrypted. -3. Keep application traffic to the destination stopped. Sign in using a temporary account - to access settings, select the backup, enter the destination transfer token, and type - `REPLACE`. The temporary account and all other destination data will be replaced. -4. Restart **all** destination Replane processes after a successful import, including - edge processes, before routing any application traffic. Restore invalidates replication - consumers; running edge processes may exit and be restarted by their supervisor. - Restarting rebuilds local replicas from the restored database. -5. Sign in with an imported account, verify configs and SDK access, switch clients to - the new URL, and disable `INSTANCE_TRANSFER_TOKEN`. Existing SDK/admin keys and - password logins are preserved. OAuth requires matching provider configuration and - redirect URLs. Sessions and pending magic links must be recreated by signing in. - -The backup includes all database-backed workspaces, projects, environments, configs, -variants, proposals, version history, audit logs, memberships, users, password hashes, -OAuth accounts, notification preferences, and SDK/admin API keys and scopes. -Environment variables and browser-local preferences are not included. Database -migration records are checked for compatibility, not replaced. Session tokens, -verification tokens, event queues, and replication consumers are discarded on restore. - -Import supports files up to 100 MiB. It validates the format, complete table/column -sets, and migration fingerprint, and restores in one PostgreSQL transaction. Invalid -relationships or other database errors roll back the entire import. Export uses a -consistent database snapshot. Route/proxy upload and timeout limits may need adjusting -for larger backups; this operation is intended for a maintenance window. - -For an empty destination without an account, the same token-protected endpoint is -available directly: `POST /api/instance-transfer?action=export` or `?action=import`, -with `x-instance-transfer-token` set to the source/destination token respectively. -Import takes the JSON file as the body and requires `x-confirm-replace-instance: replace`. -Use `Content-Type: application/json`. No account or workspace permission substitutes -for the operator token. diff --git a/next.config.ts b/next.config.ts index 265ac479..dd08f9e9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,8 +6,6 @@ const nextConfig: NextConfig = { productionBrowserSourceMaps: true, experimental: { serverSourceMaps: true, - // Instance JSON restores accept up to 100 MiB. - proxyClientMaxBodySize: 100 * 1024 * 1024, }, redirects: async () => [ { diff --git a/src/app/api/instance-transfer/route.spec.ts b/src/app/api/instance-transfer/route.spec.ts deleted file mode 100644 index 7f3f8e22..00000000 --- a/src/app/api/instance-transfer/route.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {POST} from './route'; - -const mocks = vi.hoisted(() => ({ - exportInstance: vi.fn(), - importInstance: vi.fn(), - getPgPool: vi.fn(), - free: vi.fn(), -})); -vi.mock('@/engine/core/instance-transfer', async importOriginal => ({ - ...(await importOriginal()), - exportInstance: mocks.exportInstance, - importInstance: mocks.importInstance, -})); -vi.mock('@/engine/core/pg-pool-cache', () => ({getPgPool: mocks.getPgPool})); - -function request(action: string, token?: string, body?: string, confirm = 'replace') { - return new Request(`http://localhost/api/instance-transfer?action=${action}`, { - method: 'POST', - headers: { - ...(token ? {'x-instance-transfer-token': token} : {}), - 'x-confirm-replace-instance': confirm, - }, - body, - }); -} - -beforeEach(() => { - vi.stubEnv('INSTANCE_TRANSFER_TOKEN', 'operator-secret'); - vi.stubEnv('DATABASE_URL', 'postgresql://unused'); - mocks.getPgPool.mockReturnValue([{}, mocks.free]); -}); -afterEach(() => { - vi.unstubAllEnvs(); - vi.resetAllMocks(); -}); - -describe('instance transfer endpoint', () => { - it('is unavailable without the environment variable', async () => { - vi.stubEnv('INSTANCE_TRANSFER_TOKEN', ''); - expect((await POST(request('export', 'operator-secret'))).status).toBe(404); - expect(mocks.getPgPool).not.toHaveBeenCalled(); - }); - it('denies missing and wrong tokens before touching the database', async () => { - for (const token of [undefined, 'wrong']) { - expect((await POST(request('export', token))).status).toBe(403); - expect((await POST(request('import', token, '{}'))).status).toBe(403); - } - expect(mocks.getPgPool).not.toHaveBeenCalled(); - }); - it('requires explicit replacement confirmation and valid JSON', async () => { - expect((await POST(request('import', 'operator-secret', '{}', ''))).status).toBe(400); - expect((await POST(request('import', 'operator-secret', 'not json'))).status).toBe(400); - expect(mocks.importInstance).not.toHaveBeenCalled(); - }); - it('downloads uncached JSON and releases the pool reference', async () => { - mocks.exportInstance.mockResolvedValue({format: 'replane-instance'}); - const response = await POST(request('export', 'operator-secret')); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(response.headers.get('content-disposition')).toContain('attachment;'); - expect(await response.json()).toEqual({format: 'replane-instance'}); - expect(mocks.free).toHaveBeenCalledOnce(); - }); - it('imports only after confirmation and does not leak database errors', async () => { - mocks.importInstance.mockRejectedValue(new Error('private database credentials')); - const response = await POST(request('import', 'operator-secret', '{"format":"test"}')); - expect(response.status).toBe(500); - expect(mocks.importInstance).toHaveBeenCalledWith({}, {format: 'test'}, 'public'); - expect(await response.text()).not.toContain('private database credentials'); - expect(mocks.free).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/app/api/instance-transfer/route.ts b/src/app/api/instance-transfer/route.ts deleted file mode 100644 index 668352da..00000000 --- a/src/app/api/instance-transfer/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - exportInstance, - importInstance, - InvalidInstanceBackupError, - isInstanceTransferAuthorized, -} from '@/engine/core/instance-transfer'; -import {getPgPool} from '@/engine/core/pg-pool-cache'; -import {getDatabaseUrl} from '@/environment'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; -const MAX_BYTES = 100 * 1024 * 1024; - -export async function POST(request: Request) { - if (!process.env.INSTANCE_TRANSFER_TOKEN) return new Response(null, {status: 404}); - if ( - !isInstanceTransferAuthorized( - request.headers.get('x-instance-transfer-token'), - process.env.INSTANCE_TRANSFER_TOKEN, - ) - ) { - return Response.json({error: 'Invalid instance transfer token.'}, {status: 403}); - } - const action = new URL(request.url).searchParams.get('action'); - if (action !== 'export' && action !== 'import') - return Response.json({error: 'Invalid action.'}, {status: 400}); - let backup: unknown; - if (action === 'import') { - if (request.headers.get('x-confirm-replace-instance') !== 'replace') { - return Response.json({error: 'Confirm replacement of all instance data.'}, {status: 400}); - } - // Bound actual bytes read, including requests without Content-Length. - const reader = request.body?.getReader(); - if (!reader) return Response.json({error: 'Missing backup.'}, {status: 400}); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const {done, value} = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > MAX_BYTES) { - await reader.cancel(); - return Response.json({error: 'Backup exceeds 100 MiB.'}, {status: 413}); - } - chunks.push(value); - } - try { - backup = JSON.parse(Buffer.concat(chunks).toString('utf8')); - } catch { - return Response.json({error: 'Invalid JSON file.'}, {status: 400}); - } - } - const [pool, free] = getPgPool(getDatabaseUrl()); - try { - const schema = process.env.DB_SCHEMA || 'public'; - if (action === 'export') { - const json = JSON.stringify(await exportInstance(pool, schema)); - if (Buffer.byteLength(json, 'utf8') > MAX_BYTES) { - return Response.json( - { - error: - 'This instance exceeds the 100 MiB JSON transfer limit. Use a PostgreSQL backup to migrate it.', - }, - {status: 413}, - ); - } - return new Response(json, { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'Content-Disposition': `attachment; filename="replane-instance-${new Date().toISOString().slice(0, 10)}.json"`, - }, - }); - } - await importInstance(pool, backup, schema); - return Response.json({ok: true}, {headers: {'Cache-Control': 'no-store'}}); - } catch (error) { - return Response.json( - { - error: - error instanceof InvalidInstanceBackupError - ? error.message - : 'Transfer failed. An unsuccessful import leaves the existing data intact.', - }, - {status: error instanceof InvalidInstanceBackupError ? 400 : 500}, - ); - } finally { - free(); - } -} diff --git a/src/components/settings-context.tsx b/src/components/settings-context.tsx index 7c9a8f5c..6e7814e9 100644 --- a/src/components/settings-context.tsx +++ b/src/components/settings-context.tsx @@ -5,7 +5,6 @@ import * as React from 'react'; import {SettingsDialog} from './settings-dialog'; export type SettingsSection = - | 'instance-transfer' | 'account-general' | 'account-appearance' | 'account-email-preferences' diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index cb4db131..a7d99fe2 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -20,7 +20,6 @@ import {Suspense} from 'react'; import {AccountAppearanceSettings} from './settings/account-appearance-settings'; import {AccountEmailPreferencesSettings} from './settings/account-email-preferences-settings'; import {AccountGeneralSettings} from './settings/account-general-settings'; -import {InstanceTransferSettings} from './settings/instance-transfer-settings'; import {ProjectEnvironmentsSettings} from './settings/project-environments-settings'; import {ProjectGeneralSettings} from './settings/project-general-settings'; import {ProjectMembersSettings} from './settings/project-members-settings'; @@ -29,7 +28,6 @@ import {WorkspaceGeneralSettings} from './settings/workspace-general-settings'; import {WorkspaceMembersSettings} from './settings/workspace-members-settings'; type SettingsSection = - | 'instance-transfer' | 'account-general' | 'account-appearance' | 'account-email-preferences' @@ -56,7 +54,7 @@ export function SettingsDialog({ initialSection = 'project-general', }: SettingsDialogProps) { const [activeSection, setActiveSection] = React.useState(initialSection); - const {isEmailServerConfigured, isInstanceTransferEnabled} = useAppContext(); + const {isEmailServerConfigured} = useAppContext(); // Update active section when initialSection changes and dialog opens React.useEffect(() => { @@ -67,20 +65,6 @@ export function SettingsDialog({ const navSections = React.useMemo( () => [ - ...(isInstanceTransferEnabled - ? [ - { - label: 'Instance', - items: [ - { - name: 'Backup & restore', - icon: SettingsIcon, - section: 'instance-transfer' as SettingsSection, - }, - ], - }, - ] - : []), { label: 'Account', items: [ @@ -114,15 +98,11 @@ export function SettingsDialog({ ], }, ], - [isEmailServerConfigured, isInstanceTransferEnabled], + [isEmailServerConfigured], ); const getSectionTitle = (section: SettingsSection): {title: string; breadcrumb: string[]} => { const map: Record = { - 'instance-transfer': { - title: 'Backup & restore', - breadcrumb: ['Instance', 'Backup & restore'], - }, 'account-general': {title: 'Account', breadcrumb: ['Account', 'General']}, 'account-appearance': {title: 'Appearance', breadcrumb: ['Account', 'Appearance']}, 'account-email-preferences': { @@ -175,9 +155,6 @@ export function SettingsDialog({
}> - {activeSection === 'instance-transfer' && isInstanceTransferEnabled && ( - - )} {activeSection === 'account-general' && } {activeSection === 'account-appearance' && } {activeSection === 'account-email-preferences' && isEmailServerConfigured && ( diff --git a/src/components/settings/instance-transfer-settings.spec.tsx b/src/components/settings/instance-transfer-settings.spec.tsx deleted file mode 100644 index 40bda60b..00000000 --- a/src/components/settings/instance-transfer-settings.spec.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// @vitest-environment jsdom -import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; -import {afterEach, describe, expect, it, vi} from 'vitest'; -import {InstanceTransferSettings} from './instance-transfer-settings'; - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe('instance transfer settings', () => { - it('requires a token, file, and confirmation; shows restart instructions after restore', async () => { - const fetch = vi.fn().mockResolvedValue({ok: true}); - vi.stubGlobal('fetch', fetch); - render(); - const restore = screen.getByRole('button', { - name: 'Replace instance from JSON', - }) as HTMLButtonElement; - expect(restore.disabled).toBe(true); - fireEvent.change(screen.getByLabelText('Instance transfer token'), {target: {value: 'secret'}}); - const file = new File(['{"format":"replane-instance"}'], 'backup.json', { - type: 'application/json', - }); - fireEvent.change(screen.getByLabelText('Backup JSON file'), {target: {files: [file]}}); - expect(restore.disabled).toBe(true); - fireEvent.change( - screen.getByLabelText('Type REPLACE to confirm deletion of all destination data'), - {target: {value: 'REPLACE'}}, - ); - expect(restore.disabled).toBe(false); - fireEvent.click(restore); - await waitFor(() => - expect(screen.getByRole('status').textContent).toContain('Restore complete'), - ); - expect(fetch).toHaveBeenCalledWith( - '/api/instance-transfer?action=import', - expect.objectContaining({ - method: 'POST', - body: file, - headers: expect.objectContaining({ - 'x-instance-transfer-token': 'secret', - 'x-confirm-replace-instance': 'replace', - }), - }), - ); - expect(screen.getByRole('status').textContent).toContain( - 'Restart all destination Replane processes', - ); - expect(restore.disabled).toBe(true); - expect((screen.getByLabelText('Instance transfer token') as HTMLInputElement).value).toBe(''); - }); - it('shows authorization errors and allows retry', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValue({ - ok: false, - json: async () => ({error: 'Invalid instance transfer token.'}), - }), - ); - render(); - fireEvent.change(screen.getByLabelText('Instance transfer token'), {target: {value: 'wrong'}}); - fireEvent.click(screen.getByRole('button', {name: 'Export instance JSON'})); - await waitFor(() => - expect(screen.getByRole('status').textContent).toBe('Invalid instance transfer token.'), - ); - expect( - (screen.getByRole('button', {name: 'Export instance JSON'}) as HTMLButtonElement).disabled, - ).toBe(false); - }); -}); diff --git a/src/components/settings/instance-transfer-settings.tsx b/src/components/settings/instance-transfer-settings.tsx deleted file mode 100644 index da567127..00000000 --- a/src/components/settings/instance-transfer-settings.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client'; - -import {Button} from '@/components/ui/button'; -import {Input} from '@/components/ui/input'; -import {Label} from '@/components/ui/label'; -import {useState} from 'react'; - -export function InstanceTransferSettings() { - const [token, setToken] = useState(''); - const [file, setFile] = useState(null); - const [confirmation, setConfirmation] = useState(''); - const [busy, setBusy] = useState(false); - const [message, setMessage] = useState(''); - const [restored, setRestored] = useState(false); - - async function transfer(action: 'export' | 'import') { - setBusy(true); - setMessage(''); - try { - if (action === 'import' && (!file || file.size > 100 * 1024 * 1024)) - throw new Error('Select a JSON backup up to 100 MiB.'); - const response = await fetch(`/api/instance-transfer?action=${action}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-instance-transfer-token': token, - 'x-confirm-replace-instance': confirmation === 'REPLACE' ? 'replace' : '', - }, - body: action === 'import' ? file : undefined, - }); - if (!response.ok) { - const result = await response.json().catch(() => ({})); - throw new Error(result.error || 'Instance transfer is unavailable.'); - } - if (action === 'export') { - const url = URL.createObjectURL(await response.blob()); - const link = document.createElement('a'); - link.href = url; - link.download = `replane-instance-${new Date().toISOString().slice(0, 10)}.json`; - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - setMessage( - 'Backup downloaded. Store it securely: it contains credentials and private configuration.', - ); - } else { - setRestored(true); - setToken(''); - setMessage( - 'Restore complete. Restart all destination Replane processes before use, then sign in with an imported account.', - ); - } - } catch (error) { - setMessage(error instanceof Error ? error.message : 'Transfer failed.'); - } finally { - setBusy(false); - } - } - - return ( -
-
-

Instance backup & restore

-

- Transfer all workspaces, projects, configs, members, users, API keys, history, and - database settings. -

-
-
- - setToken(e.target.value)} - disabled={busy || restored} - /> -

- Enter the INSTANCE_TRANSFER_TOKEN configured by your instance operator. -

-
- -
-

Replace this instance

-

- Import permanently replaces all destination data, including users. Use the same Replane - version on both instances. Stop writes on the source before the final export and keep - destination traffic stopped until all destination processes have restarted after import. -

-

- Environment variables are not included. Configure authentication providers on the - destination and use a fresh SECRET_KEY to invalidate previous sessions. Backups contain - sensitive credentials. Maximum file size: 100 MiB. -

- - setFile(e.target.files?.[0] ?? null)} - /> - - setConfirmation(e.target.value)} - /> - -
- {message && ( -

- {message} -

- )} -
- ); -} diff --git a/src/contexts/app-context.tsx b/src/contexts/app-context.tsx index 22aebaac..2674b518 100644 --- a/src/contexts/app-context.tsx +++ b/src/contexts/app-context.tsx @@ -24,7 +24,6 @@ interface AppContextValue { workspaces: WorkspaceSummary[]; projects: ProjectSummary[]; isEmailServerConfigured: boolean; - isInstanceTransferEnabled: boolean; // refreshes project and workspace lists refresh: () => Promise; } @@ -73,16 +72,9 @@ export function AppProvider({children}: {children: React.ReactNode}) { projects, workspaces, isEmailServerConfigured: appLayoutData.isEmailServerConfigured, - isInstanceTransferEnabled: appLayoutData.isInstanceTransferEnabled, refresh, }), - [ - projects, - workspaces, - appLayoutData.isEmailServerConfigured, - appLayoutData.isInstanceTransferEnabled, - refresh, - ], + [projects, workspaces, appLayoutData.isEmailServerConfigured, refresh], ); return {children}; diff --git a/src/engine/core/instance-transfer.ts b/src/engine/core/instance-transfer.ts deleted file mode 100644 index c9cd78a4..00000000 --- a/src/engine/core/instance-transfer.ts +++ /dev/null @@ -1,193 +0,0 @@ -import {createHash, timingSafeEqual} from 'node:crypto'; -import type {Pool, PoolClient} from 'pg'; -import type {DB} from './db'; - -// Dependency order. Transient sessions and replication state are deliberately not exported. -export const INSTANCE_TABLES = [ - 'users', - 'accounts', - 'user_credentials', - 'user_notification_preferences', - 'workspaces', - 'workspace_members', - 'projects', - 'project_users', - 'project_environments', - 'configs', - 'config_users', - 'config_variants', - 'config_proposals', - 'config_proposal_members', - 'config_proposal_variants', - 'config_versions', - 'config_version_members', - 'config_version_variants', - 'sdk_keys', - 'admin_api_keys', - 'admin_api_key_projects', - 'admin_api_key_scopes', - 'audit_logs', -] as const satisfies readonly (keyof DB)[]; -const TRANSIENT_TABLES = ['sessions', 'verification_token', 'events', 'event_consumers'] as const; -// A new database table must be explicitly classified before this code compiles. -const allTables: Record = Object.fromEntries( - [...INSTANCE_TABLES, ...TRANSIENT_TABLES, 'migrations'].map(t => [t, true]), -) as Record< - (typeof INSTANCE_TABLES)[number] | (typeof TRANSIENT_TABLES)[number] | 'migrations', - true ->; -void allTables; - -type Row = Record; -export interface InstanceBackup { - format: 'replane-instance'; - version: 1; - exportedAt: string; - schemaVersion: string; - tables: Record; -} -export class InvalidInstanceBackupError extends Error {} - -export function isInstanceTransferAuthorized(token: string | null, expected: string | undefined) { - if (!expected || !token) return false; - const hash = (value: string) => createHash('sha256').update(value).digest(); - return timingSafeEqual(hash(token), hash(expected)); -} - -const quote = (name: string) => `"${name.replaceAll('"', '""')}"`; - -async function schemaVersion(client: PoolClient): Promise { - const result = await client.query('SELECT id, sql FROM migrations ORDER BY id'); - return createHash('sha256').update(JSON.stringify(result.rows)).digest('hex'); -} - -export function validateInstanceBackup(value: unknown): InstanceBackup { - const fail = (): never => { - throw new InvalidInstanceBackupError('Invalid or incomplete Replane instance backup.'); - }; - if (!value || typeof value !== 'object') return fail(); - const backup = value as InstanceBackup; - if ( - backup.format !== 'replane-instance' || - backup.version !== 1 || - typeof backup.schemaVersion !== 'string' || - typeof backup.exportedAt !== 'string' || - !backup.tables || - typeof backup.tables !== 'object' || - Array.isArray(backup.tables) - ) - return fail(); - if (Object.keys(backup.tables).sort().join(',') !== [...INSTANCE_TABLES].sort().join(',')) - return fail(); - for (const table of INSTANCE_TABLES) { - if ( - !Array.isArray(backup.tables[table]) || - backup.tables[table].some(row => !row || typeof row !== 'object' || Array.isArray(row)) - ) - return fail(); - } - return backup; -} - -async function transaction( - pool: Pool, - schema: string, - readOnly: boolean, - run: (client: PoolClient) => Promise, -) { - const client = await pool.connect(); - try { - await client.query(readOnly ? 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY' : 'BEGIN'); - await client.query(`SET LOCAL search_path TO ${quote(schema)}`); - await client.query("SET LOCAL lock_timeout = '15s'"); - const result = await run(client); - await client.query('COMMIT'); - return result; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } -} - -export async function exportInstance(pool: Pool, schema = 'public'): Promise { - return transaction(pool, schema, true, async client => { - const tables: InstanceBackup['tables'] = {}; - const version = await schemaVersion(client); - for (const table of INSTANCE_TABLES) { - // Let PostgreSQL serialize timestamps and JSON without driver conversions. - const rowExpression = - table === 'accounts' - ? "to_jsonb(t) || jsonb_build_object('expires_at', t.expires_at::text)" - : 'row_to_json(t)'; - const result = await client.query(`SELECT ${rowExpression} AS row FROM ${quote(table)} t`); - tables[table] = result.rows.map(r => r.row); - } - return { - format: 'replane-instance', - version: 1, - exportedAt: new Date().toISOString(), - schemaVersion: version, - tables, - }; - }); -} - -export async function importInstance(pool: Pool, value: unknown, schema = 'public'): Promise { - const backup = validateInstanceBackup(value); - await transaction(pool, schema, false, async client => { - // Serialize restores and block concurrent application writes throughout replacement. - await client.query( - `LOCK TABLE ${[...INSTANCE_TABLES, ...TRANSIENT_TABLES, 'migrations'].map(quote).join(', ')} IN ACCESS EXCLUSIVE MODE`, - ); - if (backup.schemaVersion !== (await schemaVersion(client))) { - throw new InvalidInstanceBackupError( - 'Backup and destination must run the same database migrations. Use the same Replane version.', - ); - } - for (const table of INSTANCE_TABLES) { - const {rows: columns} = await client.query( - 'SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY column_name', - [schema, table], - ); - const expected = columns - .map(c => c.column_name) - .sort() - .join(','); - if (backup.tables[table].some(row => Object.keys(row).sort().join(',') !== expected)) { - throw new InvalidInstanceBackupError(`Invalid columns in ${table}.`); - } - } - // No CASCADE: an unexpected external dependency must fail rather than be erased. - // Never reset event consumer IDs: cached replicas must not match newly created consumers. - await client.query( - `TRUNCATE ${[...INSTANCE_TABLES, ...TRANSIENT_TABLES].map(quote).join(', ')}`, - ); - for (const table of INSTANCE_TABLES) { - await client.query( - `INSERT INTO ${quote(table)} SELECT * FROM json_populate_recordset(NULL::${quote(table)}, $1::json)`, - [JSON.stringify(backup.tables[table])], - ); - } - // ALTER SEQUENCE, unlike setval, rolls back if any later restore step fails. - for (const table of ['users', 'accounts'] as const) { - const {rows} = await client.query( - `SELECT pg_get_serial_sequence($1, 'id') AS sequence, (COALESCE(MAX(id), 0)::bigint + 1)::text AS next FROM ${quote(table)}`, - [table], - ); - if (rows[0].sequence) { - const next = rows[0].next as string; - if (!/^\d+$/.test(next)) throw new InvalidInstanceBackupError('Invalid sequence value.'); - // Resolve the sequence's identifiers from the catalog, never the backup file. - const {rows: sequences} = await client.query( - 'SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = $1::regclass', - [rows[0].sequence], - ); - await client.query( - `ALTER SEQUENCE ${quote(sequences[0].nspname)}.${quote(sequences[0].relname)} RESTART WITH ${next}`, - ); - } - } - }); -} diff --git a/src/engine/core/use-cases/get-app-layout-data-use-case.ts b/src/engine/core/use-cases/get-app-layout-data-use-case.ts index 2b0d4256..4cbbb5e2 100644 --- a/src/engine/core/use-cases/get-app-layout-data-use-case.ts +++ b/src/engine/core/use-cases/get-app-layout-data-use-case.ts @@ -1,4 +1,4 @@ -import {isEmailServerConfigured, isInstanceTransferEnabled} from '@/environment'; +import {isEmailServerConfigured} from '@/environment'; import {requireUserEmail, type Identity} from '../identity'; import type {ProjectListItem} from '../project-query-service'; import type {TransactionalUseCase} from '../use-case'; @@ -12,7 +12,6 @@ export interface GetAppLayoutDataResponse { projects: ProjectListItem[]; workspaces: WorkspaceListItem[]; isEmailServerConfigured: boolean; - isInstanceTransferEnabled: boolean; } export function createGetAppLayoutDataUseCase(): TransactionalUseCase< @@ -36,7 +35,6 @@ export function createGetAppLayoutDataUseCase(): TransactionalUseCase< projects, workspaces, isEmailServerConfigured: isEmailServerConfigured(), - isInstanceTransferEnabled: isInstanceTransferEnabled(), }; }; } diff --git a/src/environment.ts b/src/environment.ts index 01245b6c..b8886331 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -307,8 +307,3 @@ export function isTestingModeEnabled(): boolean { export function isPrometheusMetricsEnabled(): boolean { return process.env.PROMETHEUS_METRICS_ENABLED === 'true'; } - -/** Enables operator-only instance backup and restore. Never expose the token to clients. */ -export function isInstanceTransferEnabled(): boolean { - return !!process.env.INSTANCE_TRANSFER_TOKEN; -} diff --git a/tests/instance-transfer.spec.ts b/tests/instance-transfer.spec.ts deleted file mode 100644 index fd98a90c..00000000 --- a/tests/instance-transfer.spec.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { - exportInstance, - importInstance, - INSTANCE_TABLES, - isInstanceTransferAuthorized, - validateInstanceBackup, -} from '@/engine/core/instance-transfer'; -import {migrations} from '@/engine/core/migrations'; -import {getDatabaseUrl} from '@/environment'; -import {randomUUID} from 'node:crypto'; -import {Pool} from 'pg'; -import {afterAll, beforeAll, describe, expect, it} from 'vitest'; - -describe('instance transfer authorization', () => { - it('requires the exact nonempty operator token', () => { - expect(isInstanceTransferAuthorized(null, undefined)).toBe(false); - expect(isInstanceTransferAuthorized('', '')).toBe(false); - expect(isInstanceTransferAuthorized('wrong', 'operator-secret')).toBe(false); - expect(isInstanceTransferAuthorized('operator-secret', 'operator-secret')).toBe(true); - }); - it('rejects malformed and incomplete backups', () => { - for (const input of [null, [], {}, {format: 'replane-instance', version: 1, tables: {}}]) { - expect(() => validateInstanceBackup(input)).toThrow(); - } - }); -}); - -describe('instance transfer PostgreSQL integration', () => { - let pool: Pool; - const source = `transfer_source_${randomUUID().replaceAll('-', '')}`; - const target = `transfer_target_${randomUUID().replaceAll('-', '')}`; - const now = '2025-01-01T00:00:00.123Z'; - const workspaceId = randomUUID(); - const projectId = randomUUID(); - const environmentId = randomUUID(); - const configId = randomUUID(); - const proposalId = randomUUID(); - const versionId = randomUUID(); - const adminKeyId = randomUUID(); - - beforeAll(async () => { - pool = new Pool({connectionString: getDatabaseUrl()}); - for (const schema of [source, target]) { - const client = await pool.connect(); - try { - await client.query(`CREATE SCHEMA "${schema}"`); - await client.query(`SET search_path TO "${schema}"`); - await client.query( - 'CREATE TABLE migrations(id integer PRIMARY KEY, sql text NOT NULL, runat timestamptz NOT NULL)', - ); - for (const [index, migration] of migrations.entries()) { - await client.query(migration.sql); - await client.query('INSERT INTO migrations VALUES ($1, $2, NOW())', [ - index + 1, - migration.sql, - ]); - } - } finally { - client.release(); - } - } - }, 60000); - - afterAll(async () => { - if (!pool) return; - for (const schema of [source, target]) - await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); - await pool.end(); - }); - - it('round-trips every durable table, preserves credentials/history, clears transient state, and advances user IDs', async () => { - const backup = await exportInstance(pool, source); - const tables = backup.tables; - tables.users = [ - {id: 42, email: 'owner@example.com', name: 'Owner', image: null, emailVerified: now}, - ]; - tables.accounts = [ - { - id: 17, - userId: 42, - type: 'oauth', - provider: 'github', - providerAccountId: 'owner', - refresh_token: 'refresh', - access_token: 'access', - expires_at: '9007199254740993', - token_type: 'bearer', - scope: 'email', - id_token: null, - session_state: null, - }, - ]; - tables.user_credentials = [ - { - email: 'owner@example.com', - password_hash: 'argon2-password-hash', - created_at: now, - updated_at: now, - }, - ]; - tables.user_notification_preferences = [ - { - user_id: 42, - proposal_approved: true, - proposal_rejected: false, - proposal_waiting_for_review: true, - created_at: now, - updated_at: now, - }, - ]; - tables.workspaces = [ - { - id: workspaceId, - name: 'Migrated workspace', - logo: 'data:image/png;base64,abc', - auto_add_new_users: true, - created_at: now, - updated_at: now, - }, - ]; - tables.workspace_members = [ - { - workspace_id: workspaceId, - user_email_normalized: 'owner@example.com', - role: 'admin', - created_at: now, - updated_at: now, - }, - ]; - tables.projects = [ - { - id: projectId, - workspace_id: workspaceId, - name: 'Project', - description: 'Description', - require_proposals: true, - allow_self_approvals: false, - created_at: now, - updated_at: now, - }, - ]; - tables.project_users = [ - { - project_id: projectId, - user_email_normalized: 'owner@example.com', - role: 'admin', - created_at: now, - updated_at: now, - }, - ]; - tables.project_environments = [ - { - id: environmentId, - project_id: projectId, - name: 'Production', - order: 0, - require_proposals: true, - created_at: now, - updated_at: now, - }, - ]; - tables.configs = [ - { - id: configId, - project_id: projectId, - name: 'feature', - description: 'With JSONC', - value: '{/* keep */"enabled":true}', - overrides: '[]', - schema: null, - version: 7, - created_at: now, - updated_at: now, - }, - ]; - tables.config_users = [ - { - config_id: configId, - user_email_normalized: 'owner@example.com', - role: 'maintainer', - created_at: now, - updated_at: now, - }, - ]; - tables.config_variants = [ - { - id: randomUUID(), - config_id: configId, - environment_id: environmentId, - value: 'false', - overrides: '[]', - schema: null, - use_base_schema: true, - created_at: now, - updated_at: now, - }, - ]; - tables.config_proposals = [ - { - id: proposalId, - config_id: configId, - author_id: 42, - reviewer_id: null, - base_config_version: 7, - description: 'Proposed', - value: 'true', - overrides: '[]', - schema: null, - is_delete: false, - message: null, - created_at: now, - approved_at: null, - rejected_at: null, - rejection_reason: null, - rejected_in_favor_of_proposal_id: null, - }, - ]; - // Include a forward self-reference; all rows must be inserted in one statement. - tables.config_proposals.unshift({ - ...tables.config_proposals[0], - id: randomUUID(), - rejected_at: now, - rejection_reason: 'another_proposal_approved', - rejected_in_favor_of_proposal_id: proposalId, - }); - tables.config_proposal_members = [ - {id: randomUUID(), proposal_id: proposalId, email: 'owner@example.com', role: 'maintainer'}, - ]; - tables.config_proposal_variants = [ - { - id: randomUUID(), - proposal_id: proposalId, - environment_id: environmentId, - value: 'true', - overrides: '[]', - schema: null, - use_base_schema: true, - }, - ]; - tables.config_versions = [ - { - id: versionId, - config_id: configId, - config_name: 'feature', - author_id: 42, - proposal_id: proposalId, - version: 7, - description: 'History', - value: 'true', - overrides: '[]', - schema: null, - created_at: now, - }, - ]; - tables.config_version_members = [ - { - id: randomUUID(), - config_version_id: versionId, - email: 'owner@example.com', - role: 'maintainer', - }, - ]; - tables.config_version_variants = [ - { - id: randomUUID(), - config_version_id: versionId, - environment_id: environmentId, - value: 'false', - overrides: '[]', - schema: null, - use_base_schema: true, - }, - ]; - tables.sdk_keys = [ - { - id: randomUUID(), - project_id: projectId, - environment_id: environmentId, - name: 'SDK', - description: '', - key_hash: 'sdk-hash', - key_prefix: 'rp', - key_suffix: '1234', - created_at: now, - }, - ]; - tables.admin_api_keys = [ - { - id: adminKeyId, - workspace_id: workspaceId, - name: 'Admin', - description: '', - key_hash: 'admin-hash', - key_prefix: 'rp', - key_suffix: '4321', - created_by_email: 'owner@example.com', - expires_at: null, - last_used_at: now, - created_at: now, - updated_at: now, - }, - ]; - tables.admin_api_key_projects = [{admin_api_key_id: adminKeyId, project_id: projectId}]; - tables.admin_api_key_scopes = [{admin_api_key_id: adminKeyId, scope: 'config:read'}]; - tables.audit_logs = [ - { - id: randomUUID(), - project_id: projectId, - config_id: configId, - environment_id: environmentId, - user_id: 42, - payload: '{"type":"test"}', - created_at: now, - }, - ]; - for (const table of INSTANCE_TABLES) expect(tables[table].length).toBeGreaterThan(0); - await importInstance(pool, backup, source); - const exported = JSON.parse(JSON.stringify(await exportInstance(pool, source))); - await pool.query( - `INSERT INTO "${target}".users(name, email) VALUES ('Temporary', 'temporary@example.com')`, - ); - await pool.query( - `INSERT INTO "${target}".sessions("userId", expires, "sessionToken") VALUES (1, NOW(), 'old-session')`, - ); - const consumer = await pool.query( - `INSERT INTO "${target}".event_consumers(topic, created_at, last_used_at) VALUES ('configs', NOW(), NOW()) RETURNING id`, - ); - await importInstance(pool, exported, target); - const restored = await exportInstance(pool, target); - for (const table of INSTANCE_TABLES) { - expect(restored.tables[table]).toHaveLength(exported.tables[table].length); - expect(restored.tables[table]).toEqual(expect.arrayContaining(exported.tables[table])); - } - expect((await pool.query(`SELECT * FROM "${target}".sessions`)).rows).toHaveLength(0); - expect((await pool.query(`SELECT * FROM "${target}".event_consumers`)).rows).toHaveLength(0); - const newUser = await pool.query( - `INSERT INTO "${target}".users(name) VALUES ('New user') RETURNING id`, - ); - expect(newUser.rows[0].id).toBe(43); - const newAccount = await pool.query( - `INSERT INTO "${target}".accounts("userId", type, provider, "providerAccountId") VALUES (43, 'oauth', 'github', 'new') RETURNING id`, - ); - expect(newAccount.rows[0].id).toBe(18); - const newConsumer = await pool.query( - `INSERT INTO "${target}".event_consumers(topic, created_at, last_used_at) VALUES ('configs', NOW(), NOW()) RETURNING id`, - ); - expect(BigInt(newConsumer.rows[0].id)).toBeGreaterThan(BigInt(consumer.rows[0].id)); - }); - - it('rolls back all data when a late foreign key insert fails', async () => { - const before = await exportInstance(pool, target); - const invalid = structuredClone(before); - invalid.tables.users[0].name = 'Must roll back'; - invalid.tables.admin_api_key_projects[0].project_id = randomUUID(); - await expect(importInstance(pool, invalid, target)).rejects.toThrow(); - expect((await exportInstance(pool, target)).tables).toEqual(before.tables); - }); - - it('rejects incompatible schemas, missing tables, and missing/extra columns without replacing data', async () => { - const before = await exportInstance(pool, target); - const badSchema = structuredClone(before); - badSchema.schemaVersion = 'wrong'; - const missingTable = structuredClone(before); - delete missingTable.tables.users; - const missingColumn = structuredClone(before); - delete missingColumn.tables.users[0].name; - const extraColumn = structuredClone(before); - extraColumn.tables.users[0].unexpected = true; - for (const invalid of [badSchema, missingTable, missingColumn, extraColumn]) { - await expect(importInstance(pool, invalid, target)).rejects.toThrow(); - expect((await exportInstance(pool, target)).tables).toEqual(before.tables); - } - }); -});