From 3c870ba87eb7f69fd96459ba9a433695f2fdcbf9 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 19 May 2026 07:44:24 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(supabase):=20add=20Supabase=20Auth=20p?= =?UTF-8?q?rovider=20=E2=80=94=20Phase=201=20(users=20via=20Admin=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the foundation for migrating from Supabase Auth to WorkOS: provider metadata, an Admin API client with pagination + rate limiting, a user mapper that preserves linked OAuth identities as user metadata, a package writer that emits the canonical migration package shape, and the `export-supabase` CLI command. Phases 2–4 (Postgres-backed passwords, MFA, SSO, org schema flags, wizard integration) follow. - Provider metadata at src/providers/supabase/index.ts; registered in PROVIDERS map. - SupabaseAdminClient: paginated async iterator over /auth/v1/admin/users with Bearer + apikey headers, RateLimiter (default 50 rps), dedupe by user.id, and a SupabaseAuthError with a service-role-key hint on 401/403. - mapSupabaseUser: skips users with missing email or active ban (handles 'infinity' / unparseable timestamps as banned-forever). Preserves Supabase UUID as external_id and serializes linked identities into metadata.supabase_identities. - Package writer reuses src/package/* helpers to emit users.csv, manifest.json (provider: supabase), warnings.jsonl, skipped_users.jsonl, and pre-created headers-only files for entities filled in later phases. - CLI command export-supabase accepts --url, --service-role-key, --package, --output-dir, --rate-limit, --page-size, --entities, and (forward-compat) --db-url. Phase 1 entities restricted to users + identities. - 21 tests (5 client, 13 mapper, 3 end-to-end with fixtures); full suite 290/290 passing. README gains a Phase 1 walkthrough for Supabase. --- README.md | 45 ++++++ src/cli/commands/export-supabase.ts | 72 +++++++++ src/cli/index.ts | 2 + .../__tests__/admin-api-client.test.ts | 142 +++++++++++++++++ .../supabase/__tests__/exporter.test.ts | 145 ++++++++++++++++++ .../supabase/__tests__/user-mapper.test.ts | 114 ++++++++++++++ src/exporters/supabase/admin-api-client.ts | 104 +++++++++++++ src/exporters/supabase/exporter.ts | 96 ++++++++++++ src/exporters/supabase/package-writer.ts | 91 +++++++++++ src/exporters/supabase/types.ts | 68 ++++++++ src/exporters/supabase/user-mapper.ts | 127 +++++++++++++++ src/providers/index.ts | 2 + src/providers/supabase/index.ts | 61 ++++++++ src/shared/types.ts | 13 ++ .../supabase/fixtures/admin-users-page-1.json | 77 ++++++++++ .../supabase/fixtures/admin-users-page-2.json | 39 +++++ .../fixtures/admin-users-page-3-empty.json | 4 + 17 files changed, 1202 insertions(+) create mode 100644 src/cli/commands/export-supabase.ts create mode 100644 src/exporters/supabase/__tests__/admin-api-client.test.ts create mode 100644 src/exporters/supabase/__tests__/exporter.test.ts create mode 100644 src/exporters/supabase/__tests__/user-mapper.test.ts create mode 100644 src/exporters/supabase/admin-api-client.ts create mode 100644 src/exporters/supabase/exporter.ts create mode 100644 src/exporters/supabase/package-writer.ts create mode 100644 src/exporters/supabase/types.ts create mode 100644 src/exporters/supabase/user-mapper.ts create mode 100644 src/providers/supabase/index.ts create mode 100644 tests/providers/supabase/fixtures/admin-users-page-1.json create mode 100644 tests/providers/supabase/fixtures/admin-users-page-2.json create mode 100644 tests/providers/supabase/fixtures/admin-users-page-3-empty.json diff --git a/README.md b/README.md index 2b778bf..55062fd 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,51 @@ Continue to [Validation](#validation), [Import](#importing-users), and [Post-Imp --- +## Migrating from Supabase Auth + +> **Status — Phase 1**: users-only export via the Supabase Admin API. Password hashes, MFA TOTP factors, SAML SSO connections, and organization/role extraction land in later phases. + +### 1. Set up Supabase credentials + +You will need: + +- **Project URL** — `https://.supabase.co` (Settings → API in the Supabase dashboard). +- **Service Role Key** — the `service_role` JWT (Settings → API → Project API keys). This is _not_ the `anon` key; the service-role key is required for the Admin API. + +```bash +export SUPABASE_URL=https://your-project.supabase.co +export SUPABASE_SERVICE_ROLE_KEY=eyJ... +``` + +### 2. Export users + +```bash +workos-migrate export-supabase \ + --url "$SUPABASE_URL" \ + --service-role-key "$SUPABASE_SERVICE_ROLE_KEY" \ + --package \ + --output-dir ./migration-supabase +``` + +This produces a migration package directory with `users.csv`, `manifest.json`, `warnings.jsonl`, and `skipped_users.jsonl`. Linked OAuth identities (Google, GitHub, etc.) are preserved on each user row as `metadata.supabase_identities`. Users without an email and users currently banned (`banned_until` in the future) are skipped and recorded in `skipped_users.jsonl`. + +| Flag | Default | Description | +| --------------------- | ------- | ----------------------------------------------------------------- | +| `--rate-limit ` | 50 | Admin API requests per second | +| `--page-size ` | 1000 | Users per Admin API page | +| `--entities ` | `users` | Comma-separated entities (Phase 1: `users`, `identities` only) | + +### 3. Validate, import, and post-import + +```bash +workos-migrate validate --csv ./migration-supabase/users.csv +workos-migrate import-package ./migration-supabase +``` + +Continue to [Validation](#validation), [Import](#importing-users), and [Post-Import](#post-import-totp-and-roles) below. + +--- + ## Custom CSV If you already have a CSV in WorkOS format (see [CSV Format](#csv-format) above), skip straight to validation: diff --git a/src/cli/commands/export-supabase.ts b/src/cli/commands/export-supabase.ts new file mode 100644 index 0000000..f586c3d --- /dev/null +++ b/src/cli/commands/export-supabase.ts @@ -0,0 +1,72 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import type { SupabaseExportOptions } from '../../shared/types.js'; +import { exportSupabase } from '../../exporters/supabase/exporter.js'; + +const PHASE_1_ALLOWED_ENTITIES = ['users', 'identities']; + +function parseEntities(value: string | undefined): string[] { + if (!value) return ['users']; + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +export function registerExportSupabaseCommand(program: Command): void { + program + .command('export-supabase') + .description('Export users from Supabase Auth to a WorkOS-compatible migration package') + .requiredOption('--url ', 'Supabase project URL (e.g., https://xxxx.supabase.co)') + .requiredOption('--service-role-key ', 'Supabase service role JWT') + .option('--package', 'Write a provider-neutral migration package (required in Phase 1)') + .option('--output-dir ', 'Output directory for the migration package') + .option( + '--entities ', + 'Comma-separated entities to export (users, identities)', + 'users', + ) + .option('--rate-limit ', 'Admin API requests per second', '50') + .option('--page-size ', 'Admin API page size', '1000') + .option( + '--db-url ', + 'Postgres connection string (accepted for forward compatibility; unused in Phase 1)', + ) + .option('--quiet', 'Suppress progress output') + .action(async (opts) => { + try { + if (!opts.package) { + throw new Error('--package is required for Supabase export'); + } + if (!opts.outputDir) { + throw new Error('--output-dir is required when using --package'); + } + + const entities = parseEntities(opts.entities); + const unsupported = entities.filter((e) => !PHASE_1_ALLOWED_ENTITIES.includes(e)); + if (unsupported.length > 0) { + throw new Error( + `Phase 1 supports only ${PHASE_1_ALLOWED_ENTITIES.join(', ')}. Unsupported: ${unsupported.join( + ', ', + )}. MFA, SSO, and organizations land in later phases.`, + ); + } + + const options: SupabaseExportOptions = { + url: opts.url, + serviceRoleKey: opts.serviceRoleKey, + dbUrl: opts.dbUrl, + outputDir: opts.outputDir, + entities, + rateLimit: parseInt(opts.rateLimit, 10), + pageSize: parseInt(opts.pageSize, 10), + quiet: opts.quiet ?? false, + }; + + await exportSupabase(options); + } catch (error: unknown) { + console.error(chalk.red(`\nExport failed: ${(error as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 32da31e..305c53c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,6 +4,7 @@ import { registerImportPackageCommand } from './commands/import-package.js'; import { registerValidateCommand } from './commands/validate.js'; import { registerExportAuth0Command } from './commands/export-auth0.js'; import { registerExportCognitoCommand } from './commands/export-cognito.js'; +import { registerExportSupabaseCommand } from './commands/export-supabase.js'; import { registerMergePasswordsCommand } from './commands/merge-passwords.js'; import { registerTransformClerkCommand } from './commands/transform-clerk.js'; import { registerTransformFirebaseCommand } from './commands/transform-firebase.js'; @@ -24,6 +25,7 @@ registerImportPackageCommand(program); registerValidateCommand(program); registerExportAuth0Command(program); registerExportCognitoCommand(program); +registerExportSupabaseCommand(program); registerMergePasswordsCommand(program); registerTransformClerkCommand(program); registerTransformFirebaseCommand(program); diff --git a/src/exporters/supabase/__tests__/admin-api-client.test.ts b/src/exporters/supabase/__tests__/admin-api-client.test.ts new file mode 100644 index 0000000..602a920 --- /dev/null +++ b/src/exporters/supabase/__tests__/admin-api-client.test.ts @@ -0,0 +1,142 @@ +import { jest } from '@jest/globals'; +import { SupabaseAdminClient } from '../admin-api-client.js'; +import { SupabaseAuthError, type SupabaseAdminUser } from '../types.js'; + +describe('SupabaseAdminClient', () => { + let fetchMock: jest.MockedFunction; + + beforeEach(() => { + fetchMock = jest.fn() as jest.MockedFunction; + global.fetch = fetchMock; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('paginates through users and stops on the first empty page', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ users: makeUsers(['11111111-1111-1111-1111-111111111111']) }), + ) + .mockResolvedValueOnce( + jsonResponse({ users: makeUsers(['22222222-2222-2222-2222-222222222222']) }), + ) + .mockResolvedValueOnce(jsonResponse({ users: [] })); + + const client = createClient({ pageSize: 1 }); + + const collected: string[] = []; + for await (const user of client.listUsers()) { + collected.push(user.id); + } + + expect(collected).toEqual([ + '11111111-1111-1111-1111-111111111111', + '22222222-2222-2222-2222-222222222222', + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('sends Authorization: Bearer and apikey headers using the service-role key', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ users: [] })); + + const client = createClient({ pageSize: 10 }); + for await (const _user of client.listUsers()) { + // exhaust + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [calledUrl, calledInit] = fetchMock.mock.calls[0]; + expect(calledUrl).toBe('https://demo.supabase.co/auth/v1/admin/users?page=1&per_page=10'); + const headers = (calledInit as RequestInit).headers as Record; + expect(headers.Authorization).toBe('Bearer sb-service-role-jwt'); + expect(headers.apikey).toBe('sb-service-role-jwt'); + }); + + it('throws SupabaseAuthError with a service-role hint on 401', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'Invalid JWT' }, 401)); + + const client = createClient({ pageSize: 10 }); + let caught: unknown; + try { + for await (const _user of client.listUsers()) { + // unreachable + } + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(SupabaseAuthError); + const err = caught as SupabaseAuthError; + expect(err.statusCode).toBe(401); + expect(err.hint).toMatch(/service-role/i); + expect(err.message).toMatch(/service-role/i); + }); + + it('dedupes users that appear in multiple pages and reports the first duplicate', async () => { + const sharedId = '99999999-9999-9999-9999-999999999999'; + fetchMock + .mockResolvedValueOnce(jsonResponse({ users: makeUsers([sharedId, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']) })) + .mockResolvedValueOnce(jsonResponse({ users: makeUsers([sharedId]) })) + .mockResolvedValueOnce(jsonResponse({ users: [] })); + + const client = createClient({ pageSize: 2 }); + const collected: string[] = []; + const duplicates: string[] = []; + + for await (const user of client.listUsers({ onDuplicate: (id) => duplicates.push(id) })) { + collected.push(user.id); + } + + expect(collected).toEqual([sharedId, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']); + expect(duplicates).toEqual([sharedId]); + }); + + it('testConnection returns success on 200 and failure with error message on auth failure', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ users: [] })); + const ok = await createClient().testConnection(); + expect(ok).toEqual({ success: true }); + + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'bad key' }, 403)); + const fail = await createClient().testConnection(); + expect(fail.success).toBe(false); + expect(fail.error).toMatch(/Supabase Admin API error/); + }); +}); + +interface CreateClientOptions { + pageSize?: number; +} + +function createClient(options: CreateClientOptions = {}): SupabaseAdminClient { + return new SupabaseAdminClient({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + rateLimit: 1000, + pageSize: options.pageSize, + }); +} + +function makeUsers(ids: string[]): SupabaseAdminUser[] { + return ids.map((id) => ({ + id, + email: `${id}@example.com`, + created_at: '2025-01-01T00:00:00.000Z', + user_metadata: {}, + app_metadata: {}, + identities: [], + })); +} + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: () => null, + }, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; +} diff --git a/src/exporters/supabase/__tests__/exporter.test.ts b/src/exporters/supabase/__tests__/exporter.test.ts new file mode 100644 index 0000000..1e9a388 --- /dev/null +++ b/src/exporters/supabase/__tests__/exporter.test.ts @@ -0,0 +1,145 @@ +import { jest } from '@jest/globals'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { exportSupabase } from '../exporter.js'; +import { streamCSV } from '../../../shared/csv-utils.js'; + +const FIXTURES_DIR = path.resolve(process.cwd(), 'tests/providers/supabase/fixtures'); + +describe('exportSupabase (end-to-end)', () => { + let tmpDir: string; + let fetchMock: jest.MockedFunction; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supabase-export-test-')); + fetchMock = jest.fn() as jest.MockedFunction; + global.fetch = fetchMock; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it('produces a complete package directory from two pages of users', async () => { + const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); + const page2 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-2.json'), 'utf-8')); + const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + + fetchMock + // testConnection() makes a per_page=1 probe call first + .mockResolvedValueOnce(jsonResponse({ users: [page1.users[0]] })) + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(page2)) + .mockResolvedValueOnce(jsonResponse(empty)); + + const summary = await exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + outputDir: tmpDir, + entities: ['users', 'identities'], + rateLimit: 1000, + pageSize: 3, + quiet: true, + }); + + // Page 1 has 3 users (1 missing email → skip), page 2 has 2 users (1 banned → skip) + expect(summary.totalUsers).toBe(3); + expect(summary.skippedUsers).toBe(2); + + const usersCsv = path.join(tmpDir, 'users.csv'); + expect(fs.existsSync(usersCsv)).toBe(true); + + const rows: Record[] = []; + for await (const row of streamCSV(usersCsv)) { + rows.push(row); + } + expect(rows).toHaveLength(3); + + const alice = rows.find((r) => r.email === 'alice@example.com'); + expect(alice).toBeDefined(); + expect(alice?.first_name).toBe('Alice'); + expect(alice?.last_name).toBe('Anderson'); + expect(alice?.email_verified).toBe('true'); + expect(alice?.external_id).toBe('11111111-1111-1111-1111-111111111111'); + + const bob = rows.find((r) => r.email === 'bob.builder@example.com'); + expect(bob).toBeDefined(); + const bobMetadata = JSON.parse(bob?.metadata as string); + expect(bobMetadata.supabase_identities).toHaveLength(2); + expect(bobMetadata.supabase_identities.map((i: { provider: string }) => i.provider)).toEqual([ + 'google', + 'github', + ]); + + const dana = rows.find((r) => r.email === 'dana@example.com'); + expect(dana).toBeDefined(); + expect(dana?.email_verified).toBe('false'); + + // Manifest + const manifestRaw = await fsPromises.readFile(path.join(tmpDir, 'manifest.json'), 'utf-8'); + const manifest = JSON.parse(manifestRaw); + expect(manifest.provider).toBe('supabase'); + expect(manifest.sourceTenant).toBe('https://demo.supabase.co'); + expect(manifest.entitiesRequested).toEqual(['users', 'identities']); + expect(manifest.entitiesExported.users).toBe(3); + expect(manifest.secretsRedacted).toBe(true); + + // Skipped users JSONL + const skippedRaw = await fsPromises.readFile(path.join(tmpDir, 'skipped_users.jsonl'), 'utf-8'); + const skippedLines = skippedRaw.split('\n').filter(Boolean); + expect(skippedLines).toHaveLength(2); + const reasons = skippedLines.map((line) => JSON.parse(line).reason); + expect(reasons).toContain('Missing email address'); + expect(reasons).toContain('Banned user'); + + // Warnings file exists (even if empty) + expect(fs.existsSync(path.join(tmpDir, 'warnings.jsonl'))).toBe(true); + + // Empty package files left as headers-only + expect(fs.existsSync(path.join(tmpDir, 'organizations.csv'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'totp_secrets.csv'))).toBe(true); + }); + + it('throws when Phase 1 receives an unsupported entity', async () => { + await expect( + exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + outputDir: tmpDir, + entities: ['users', 'mfa'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + }), + ).rejects.toThrow(/Phase 1 supports only/); + }); + + it('throws a clear connection error on 401 from the Admin API', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'Invalid JWT' }, 401)); + + await expect( + exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'wrong-key', + outputDir: tmpDir, + entities: ['users'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + }), + ).rejects.toThrow(/Supabase connection failed/); + }); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} diff --git a/src/exporters/supabase/__tests__/user-mapper.test.ts b/src/exporters/supabase/__tests__/user-mapper.test.ts new file mode 100644 index 0000000..98fe2ba --- /dev/null +++ b/src/exporters/supabase/__tests__/user-mapper.test.ts @@ -0,0 +1,114 @@ +import { mapSupabaseUser } from '../user-mapper.js'; +import type { SupabaseAdminUser } from '../types.js'; + +function baseUser(overrides: Partial = {}): SupabaseAdminUser { + return { + id: '11111111-1111-1111-1111-111111111111', + email: 'user@example.com', + email_confirmed_at: '2025-01-15T10:30:00.000Z', + created_at: '2025-01-15T10:00:00.000Z', + user_metadata: {}, + app_metadata: {}, + identities: [], + ...overrides, + }; +} + +describe('mapSupabaseUser', () => { + it('skips users with no email', () => { + const result = mapSupabaseUser(baseUser({ email: undefined })); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('Missing email address'); + }); + + it('skips users with empty-string email', () => { + const result = mapSupabaseUser(baseUser({ email: ' ' })); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('Missing email address'); + }); + + it('skips users banned in the future', () => { + const result = mapSupabaseUser(baseUser({ banned_until: '2099-01-01T00:00:00.000Z' })); + expect(result.skipped).toBe(true); + expect(result.skipReason).toBe('Banned user'); + }); + + it('does not skip users whose ban has expired', () => { + const result = mapSupabaseUser(baseUser({ banned_until: '2000-01-01T00:00:00.000Z' })); + expect(result.skipped).toBe(false); + }); + + it("treats banned_until = 'infinity' as banned forever", () => { + const result = mapSupabaseUser(baseUser({ banned_until: 'infinity' })); + expect(result.skipped).toBe(true); + }); + + it('uses first_name / last_name from user_metadata when present', () => { + const result = mapSupabaseUser( + baseUser({ user_metadata: { first_name: 'Alice', last_name: 'Anderson' } }), + ); + expect(result.csvRow.first_name).toBe('Alice'); + expect(result.csvRow.last_name).toBe('Anderson'); + }); + + it("splits full_name with the 'first-space' strategy when explicit names are absent", () => { + const result = mapSupabaseUser( + baseUser({ user_metadata: { full_name: 'Bob Junior Builder' } }), + ); + expect(result.csvRow.first_name).toBe('Bob'); + expect(result.csvRow.last_name).toBe('Junior Builder'); + }); + + it("falls back to user_metadata.name when full_name is missing", () => { + const result = mapSupabaseUser(baseUser({ user_metadata: { name: 'Charlie Chaplin' } })); + expect(result.csvRow.first_name).toBe('Charlie'); + expect(result.csvRow.last_name).toBe('Chaplin'); + }); + + it('sets email_verified=true only when email_confirmed_at is non-null', () => { + const verified = mapSupabaseUser(baseUser({ email_confirmed_at: '2025-01-01T00:00:00Z' })); + expect(verified.csvRow.email_verified).toBe('true'); + + const unverified = mapSupabaseUser(baseUser({ email_confirmed_at: null })); + expect(unverified.csvRow.email_verified).toBe('false'); + }); + + it('writes the Supabase user.id as external_id', () => { + const result = mapSupabaseUser(baseUser()); + expect(result.csvRow.external_id).toBe('11111111-1111-1111-1111-111111111111'); + }); + + it('records all linked identities in metadata.supabase_identities', () => { + const result = mapSupabaseUser( + baseUser({ + identities: [ + { provider: 'google', provider_id: 'g-1', identity_data: { sub: 'g-1' } }, + { provider: 'github', provider_id: 'gh-2' }, + { provider: 'email', provider_id: 'user@example.com' }, + ], + }), + ); + + const metadata = JSON.parse(result.csvRow.metadata as string); + expect(metadata.supabase_identities).toHaveLength(3); + expect(metadata.supabase_identities[0].provider).toBe('google'); + expect(metadata.supabase_identities[0].identity_data).toEqual({ sub: 'g-1' }); + expect(metadata.supabase_identities[1].provider).toBe('github'); + expect(metadata.supabase_identities[2].provider).toBe('email'); + expect(metadata.supabase_uid).toBe('11111111-1111-1111-1111-111111111111'); + }); + + it('includes app_metadata in user metadata when non-empty', () => { + const result = mapSupabaseUser( + baseUser({ app_metadata: { provider: 'google', providers: ['google', 'github'] } }), + ); + const metadata = JSON.parse(result.csvRow.metadata as string); + expect(metadata.app_metadata.provider).toBe('google'); + }); + + it('omits supabase_identities when there are no identities', () => { + const result = mapSupabaseUser(baseUser({ identities: [] })); + const metadata = JSON.parse(result.csvRow.metadata as string); + expect(metadata.supabase_identities).toBeUndefined(); + }); +}); diff --git a/src/exporters/supabase/admin-api-client.ts b/src/exporters/supabase/admin-api-client.ts new file mode 100644 index 0000000..cf0c18a --- /dev/null +++ b/src/exporters/supabase/admin-api-client.ts @@ -0,0 +1,104 @@ +import { RateLimiter } from '../../shared/rate-limiter.js'; +import { + SupabaseAuthError, + type SupabaseAdminListResponse, + type SupabaseAdminUser, +} from './types.js'; + +export interface SupabaseAdminClientOptions { + url: string; + serviceRoleKey: string; + rateLimit?: number; + pageSize?: number; +} + +export interface SupabaseAdminListIteratorOptions { + onDuplicate?: (userId: string) => void; + onMalformedUser?: (warning: string) => void; +} + +const DEFAULT_RATE_LIMIT = 50; +const DEFAULT_PAGE_SIZE = 1000; +const MAX_PAGE_GUARD = 10_000; + +export class SupabaseAdminClient { + private readonly baseUrl: string; + private readonly serviceRoleKey: string; + private readonly rateLimiter: RateLimiter; + private readonly pageSize: number; + + constructor(options: SupabaseAdminClientOptions) { + this.baseUrl = options.url.replace(/\/$/, ''); + this.serviceRoleKey = options.serviceRoleKey; + this.rateLimiter = new RateLimiter(options.rateLimit ?? DEFAULT_RATE_LIMIT); + this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; + } + + async testConnection(): Promise<{ success: boolean; error?: string }> { + try { + await this.fetchPage(1, 1); + return { success: true }; + } catch (error: unknown) { + return { success: false, error: (error as Error).message }; + } + } + + async *listUsers( + options: SupabaseAdminListIteratorOptions = {}, + ): AsyncIterableIterator { + const seen = new Set(); + let duplicateWarned = false; + + for (let page = 1; page <= MAX_PAGE_GUARD; page++) { + const response = await this.fetchPage(page, this.pageSize); + const users = response.users ?? []; + if (users.length === 0) return; + + for (const user of users) { + if (!user.id) { + options.onMalformedUser?.( + `Admin API returned a user without an id on page ${page}; skipped`, + ); + continue; + } + if (seen.has(user.id)) { + if (!duplicateWarned) { + duplicateWarned = true; + options.onDuplicate?.(user.id); + } + continue; + } + seen.add(user.id); + yield user; + } + + // Defensive: if API returned fewer than a full page, assume terminal. + if (users.length < this.pageSize) return; + } + } + + private async fetchPage(page: number, perPage: number): Promise { + await this.rateLimiter.acquire(); + + const url = `${this.baseUrl}/auth/v1/admin/users?page=${page}&per_page=${perPage}`; + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.serviceRoleKey}`, + apikey: this.serviceRoleKey, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text(); + const hint = + response.status === 401 || response.status === 403 + ? 'Confirm you are using the service-role key (not the anon key) and that SUPABASE_URL matches your project.' + : undefined; + throw new SupabaseAuthError(response.status, body, hint); + } + + return (await response.json()) as SupabaseAdminListResponse; + } +} diff --git a/src/exporters/supabase/exporter.ts b/src/exporters/supabase/exporter.ts new file mode 100644 index 0000000..2ead9e1 --- /dev/null +++ b/src/exporters/supabase/exporter.ts @@ -0,0 +1,96 @@ +import type { ExportSummary, SupabaseExportOptions } from '../../shared/types.js'; +import * as logger from '../../shared/logger.js'; +import { SupabaseAdminClient } from './admin-api-client.js'; +import { mapSupabaseUser } from './user-mapper.js'; +import { openSupabasePackage } from './package-writer.js'; + +const PHASE_1_ALLOWED_ENTITIES = new Set(['users', 'identities']); + +export async function exportSupabase(options: SupabaseExportOptions): Promise { + if (!options.outputDir) { + throw new Error('--output-dir is required for Supabase package export'); + } + + const requested = options.entities.length > 0 ? options.entities : ['users']; + const unsupported = requested.filter((entity) => !PHASE_1_ALLOWED_ENTITIES.has(entity)); + if (unsupported.length > 0) { + throw new Error( + `Phase 1 supports only 'users' and 'identities' entities; unsupported: ${unsupported.join( + ', ', + )}. MFA, SSO, and organizations land in later phases.`, + ); + } + + const client = new SupabaseAdminClient({ + url: options.url, + serviceRoleKey: options.serviceRoleKey, + rateLimit: options.rateLimit, + pageSize: options.pageSize, + }); + + if (!options.quiet) logger.info('Connecting to Supabase Admin API...'); + + const connectionTest = await client.testConnection(); + if (!connectionTest.success) { + throw new Error(`Supabase connection failed: ${connectionTest.error}`); + } + + if (!options.quiet) logger.success('Connected to Supabase'); + + const startTime = Date.now(); + const pkg = await openSupabasePackage(options.outputDir); + + const iterator = client.listUsers({ + onDuplicate: (userId) => { + pkg.stats.warnings.push(`Duplicate user.id encountered during pagination: ${userId}`); + }, + onMalformedUser: (warning) => { + pkg.stats.warnings.push(warning); + }, + }); + + for await (const user of iterator) { + pkg.stats.totalFetched++; + const mapped = mapSupabaseUser(user); + + if (mapped.warnings.length > 0) pkg.stats.warnings.push(...mapped.warnings); + + if (mapped.skipped) { + pkg.stats.skipped++; + pkg.stats.skippedRecords.push({ + supabase_uid: user.id, + email: user.email ?? '', + reason: mapped.skipReason ?? 'unknown', + }); + continue; + } + + pkg.writeUser(mapped.csvRow); + pkg.stats.exported++; + + if (!options.quiet && pkg.stats.totalFetched % 1000 === 0) { + logger.info(` Processed ${pkg.stats.totalFetched} users (${pkg.stats.exported} exported)`); + } + } + + await pkg.finalize({ url: options.url, entitiesRequested: requested }); + + const duration = Date.now() - startTime; + + if (!options.quiet) { + logger.success(`\nExport complete`); + logger.info(` Total fetched: ${pkg.stats.totalFetched}`); + logger.info(` Exported: ${pkg.stats.exported}`); + logger.info(` Skipped: ${pkg.stats.skipped}`); + logger.info(` Warnings: ${pkg.stats.warnings.length}`); + logger.info(` Duration: ${duration}ms`); + logger.info(` Output: ${pkg.rootDir}`); + } + + return { + totalUsers: pkg.stats.exported, + totalOrgs: 0, + skippedUsers: pkg.stats.skipped, + duration, + }; +} diff --git a/src/exporters/supabase/package-writer.ts b/src/exporters/supabase/package-writer.ts new file mode 100644 index 0000000..8b71160 --- /dev/null +++ b/src/exporters/supabase/package-writer.ts @@ -0,0 +1,91 @@ +import path from 'node:path'; +import { createCSVWriter } from '../../shared/csv-utils.js'; +import { + USER_CSV_HEADERS, + createMigrationPackageManifest, +} from '../../package/manifest.js'; +import { + createEmptyPackageFiles, + getPackageFilePath, + writeMigrationPackageManifest, + writePackageJsonlRecords, +} from '../../package/writer.js'; +import type { CSVRow } from '../../shared/types.js'; +import type { SupabaseExportStats } from './types.js'; + +export interface SupabaseWriterContext { + rootDir: string; + stats: SupabaseExportStats; + writeUser: (row: CSVRow) => void; + finalize: (options: { url: string; entitiesRequested: string[] }) => Promise; +} + +function normalizeUserRow(row: CSVRow): Record { + const normalized: Record = {}; + for (const header of USER_CSV_HEADERS) { + const value = row[header]; + if (value === undefined || value === null) { + normalized[header] = ''; + } else if (typeof value === 'boolean') { + normalized[header] = value ? 'true' : 'false'; + } else { + normalized[header] = String(value); + } + } + return normalized; +} + +export async function openSupabasePackage(outputDir: string): Promise { + const rootDir = path.resolve(outputDir); + await createEmptyPackageFiles(rootDir); + + const usersWriter = createCSVWriter(getPackageFilePath(rootDir, 'users'), [...USER_CSV_HEADERS]); + + const stats: SupabaseExportStats = { + totalFetched: 0, + exported: 0, + skipped: 0, + warnings: [], + skippedRecords: [], + }; + + return { + rootDir, + stats, + writeUser(row: CSVRow) { + usersWriter.write(normalizeUserRow(row)); + }, + async finalize(options) { + await usersWriter.end(); + + await writePackageJsonlRecords(rootDir, 'warnings', stats.warnings); + await writePackageJsonlRecords(rootDir, 'skippedUsers', stats.skippedRecords); + + const manifest = createMigrationPackageManifest({ + provider: 'supabase', + sourceTenant: options.url, + entitiesRequested: options.entitiesRequested, + entitiesExported: { + users: stats.exported, + organizations: 0, + memberships: 0, + roleDefinitions: 0, + userRoleAssignments: 0, + totpSecrets: 0, + samlConnections: 0, + oidcConnections: 0, + customAttributeMappings: 0, + proxyRoutes: 0, + uploadUsers: 0, + uploadOrganizations: 0, + uploadMemberships: 0, + warnings: stats.warnings.length, + skippedUsers: stats.skippedRecords.length, + }, + warnings: stats.warnings, + }); + + await writeMigrationPackageManifest(rootDir, manifest); + }, + }; +} diff --git a/src/exporters/supabase/types.ts b/src/exporters/supabase/types.ts new file mode 100644 index 0000000..c2c8ace --- /dev/null +++ b/src/exporters/supabase/types.ts @@ -0,0 +1,68 @@ +export interface SupabaseIdentity { + id?: string; + user_id?: string; + identity_id?: string; + identity_data?: Record; + provider: string; + provider_id?: string; + last_sign_in_at?: string; + created_at?: string; + updated_at?: string; +} + +export interface SupabaseAdminUser { + id: string; + aud?: string; + role?: string; + email?: string; + email_confirmed_at?: string | null; + phone?: string; + phone_confirmed_at?: string | null; + confirmed_at?: string | null; + invited_at?: string | null; + last_sign_in_at?: string | null; + created_at: string; + updated_at?: string; + banned_until?: string | null; + user_metadata?: Record; + app_metadata?: Record; + identities?: SupabaseIdentity[]; + is_anonymous?: boolean; + [key: string]: unknown; +} + +export interface SupabaseAdminListResponse { + users: SupabaseAdminUser[]; + aud?: string; + total_pages?: number; + next_page?: number | string | null; +} + +export interface SupabaseSkippedRecord { + supabase_uid: string; + email: string; + reason: string; +} + +export interface SupabaseExportStats { + totalFetched: number; + exported: number; + skipped: number; + warnings: string[]; + skippedRecords: SupabaseSkippedRecord[]; +} + +export class SupabaseAuthError extends Error { + statusCode: number; + body: string; + hint?: string; + + constructor(statusCode: number, body: string, hint?: string) { + const base = `Supabase Admin API error (${statusCode}): ${body}`; + super(hint ? `${base}\n hint: ${hint}` : base); + this.name = 'SupabaseAuthError'; + this.statusCode = statusCode; + this.body = body; + this.hint = hint; + } +} diff --git a/src/exporters/supabase/user-mapper.ts b/src/exporters/supabase/user-mapper.ts new file mode 100644 index 0000000..74f462b --- /dev/null +++ b/src/exporters/supabase/user-mapper.ts @@ -0,0 +1,127 @@ +import type { CSVRow } from '../../shared/types.js'; +import type { SupabaseAdminUser, SupabaseIdentity } from './types.js'; + +export interface MappedSupabaseUser { + csvRow: CSVRow; + skipped: boolean; + skipReason?: string; + warnings: string[]; +} + +interface SplitName { + firstName: string; + lastName: string; +} + +function splitDisplayName(displayName: string | undefined): SplitName { + if (!displayName?.trim()) return { firstName: '', lastName: '' }; + const name = displayName.trim(); + const idx = name.indexOf(' '); + if (idx === -1) return { firstName: name, lastName: '' }; + return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) }; +} + +function readStringField(source: Record | undefined, key: string): string | undefined { + const value = source?.[key]; + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +function resolveNames(user: SupabaseAdminUser): SplitName { + const meta = user.user_metadata; + const explicitFirst = readStringField(meta, 'first_name'); + const explicitLast = readStringField(meta, 'last_name'); + if (explicitFirst || explicitLast) { + return { firstName: explicitFirst ?? '', lastName: explicitLast ?? '' }; + } + + const fullName = readStringField(meta, 'full_name') ?? readStringField(meta, 'name'); + if (fullName) return splitDisplayName(fullName); + + return { firstName: '', lastName: '' }; +} + +/** + * Returns true if the user is banned (now or forever). `banned_until` may be + * `null`, an ISO timestamp, or `'infinity'` (Postgres timestamp keyword). + * Treat unparseable values as banned-forever to err on the side of skipping. + */ +function isBanned(bannedUntil: string | null | undefined): boolean { + if (bannedUntil === undefined || bannedUntil === null) return false; + if (bannedUntil === '') return false; + + const trimmed = bannedUntil.trim().toLowerCase(); + if (trimmed === 'infinity') return true; + if (trimmed === '-infinity') return false; + + const ms = Date.parse(bannedUntil); + if (Number.isNaN(ms)) return true; + return ms > Date.now(); +} + +function summarizeIdentity(identity: SupabaseIdentity): Record { + const summary: Record = { provider: identity.provider }; + if (identity.provider_id) summary.provider_id = identity.provider_id; + if (identity.identity_data && Object.keys(identity.identity_data).length > 0) { + summary.identity_data = identity.identity_data; + } + if (identity.last_sign_in_at) summary.last_sign_in_at = identity.last_sign_in_at; + return summary; +} + +function buildMetadata(user: SupabaseAdminUser): Record { + const metadata: Record = { supabase_uid: user.id }; + + if (user.identities && user.identities.length > 0) { + metadata.supabase_identities = user.identities.map(summarizeIdentity); + } + + if (user.app_metadata && Object.keys(user.app_metadata).length > 0) { + metadata.app_metadata = user.app_metadata; + } + + if (user.last_sign_in_at) metadata.last_sign_in_at = user.last_sign_in_at; + if (user.phone) metadata.phone = user.phone; + if (user.created_at) metadata.supabase_created_at = user.created_at; + + return metadata; +} + +export function mapSupabaseUser(user: SupabaseAdminUser): MappedSupabaseUser { + const warnings: string[] = []; + + const email = user.email?.trim(); + if (!email) { + return { + csvRow: {}, + skipped: true, + skipReason: 'Missing email address', + warnings, + }; + } + + if (isBanned(user.banned_until)) { + return { + csvRow: {}, + skipped: true, + skipReason: 'Banned user', + warnings, + }; + } + + const { firstName, lastName } = resolveNames(user); + const emailVerified = + user.email_confirmed_at !== null && user.email_confirmed_at !== undefined; + + const metadata = buildMetadata(user); + + const csvRow: CSVRow = { + email, + first_name: firstName || undefined, + last_name: lastName || undefined, + email_verified: emailVerified ? 'true' : 'false', + external_id: user.id, + metadata: JSON.stringify(metadata), + }; + + return { csvRow, skipped: false, warnings }; +} diff --git a/src/providers/index.ts b/src/providers/index.ts index e54bd47..e086017 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -3,6 +3,7 @@ import { auth0Provider } from './auth0/index.js'; import { clerkProvider } from './clerk/index.js'; import { firebaseProvider } from './firebase/index.js'; import { cognitoProvider } from './cognito/index.js'; +import { supabaseProvider } from './supabase/index.js'; import { csvProvider } from './csv/index.js'; export const PROVIDERS: Record = { @@ -10,6 +11,7 @@ export const PROVIDERS: Record = { clerk: clerkProvider, firebase: firebaseProvider, cognito: cognitoProvider, + supabase: supabaseProvider, csv: csvProvider, }; diff --git a/src/providers/supabase/index.ts b/src/providers/supabase/index.ts new file mode 100644 index 0000000..7b7b7aa --- /dev/null +++ b/src/providers/supabase/index.ts @@ -0,0 +1,61 @@ +import type { Provider } from '../../shared/types.js'; + +export const supabaseProvider: Provider = { + name: 'supabase', + displayName: 'Supabase Auth', + credentials: [ + { + key: 'url', + name: 'Supabase project URL (https://xxxx.supabase.co)', + type: 'input', + required: true, + envVar: 'SUPABASE_URL', + }, + { + key: 'serviceRoleKey', + name: 'Service Role Key (JWT)', + type: 'password', + required: true, + envVar: 'SUPABASE_SERVICE_ROLE_KEY', + }, + { + key: 'dbUrl', + name: 'Postgres connection string (optional — needed for passwords/MFA/SSO/orgs)', + type: 'password', + required: false, + envVar: 'SUPABASE_DB_URL', + }, + ], + entities: [ + { + key: 'users', + name: 'Users', + description: 'auth.users via Admin API', + enabled: true, + }, + { + key: 'identities', + name: 'OAuth identities', + description: 'Linked OAuth providers (stored as user metadata)', + enabled: true, + }, + { + key: 'mfa', + name: 'MFA TOTP factors', + description: 'Requires Postgres connection', + enabled: false, + }, + { + key: 'sso', + name: 'SAML SSO connections', + description: 'Requires Postgres connection', + enabled: false, + }, + { + key: 'organizations', + name: 'Organizations', + description: 'From user-supplied org table — requires Postgres connection', + enabled: false, + }, + ], +}; diff --git a/src/shared/types.ts b/src/shared/types.ts index e49fdfd..f93759e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -129,6 +129,19 @@ export interface Auth0ExportOptions { quiet: boolean; } +// --- Supabase Types --- + +export interface SupabaseExportOptions { + url: string; + serviceRoleKey: string; + dbUrl?: string; + outputDir: string; + entities: string[]; + rateLimit: number; + pageSize: number; + quiet: boolean; +} + export interface ExportSummary { totalUsers: number; totalOrgs: number; diff --git a/tests/providers/supabase/fixtures/admin-users-page-1.json b/tests/providers/supabase/fixtures/admin-users-page-1.json new file mode 100644 index 0000000..02c1e55 --- /dev/null +++ b/tests/providers/supabase/fixtures/admin-users-page-1.json @@ -0,0 +1,77 @@ +{ + "aud": "authenticated", + "users": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "aud": "authenticated", + "role": "authenticated", + "email": "alice@example.com", + "email_confirmed_at": "2025-01-15T10:30:00.000Z", + "phone": "", + "created_at": "2025-01-15T10:00:00.000Z", + "updated_at": "2025-01-20T12:00:00.000Z", + "last_sign_in_at": "2025-03-01T08:15:00.000Z", + "banned_until": null, + "user_metadata": { + "first_name": "Alice", + "last_name": "Anderson" + }, + "app_metadata": { + "provider": "email", + "providers": ["email"] + }, + "identities": [ + { + "id": "id-1", + "user_id": "11111111-1111-1111-1111-111111111111", + "provider": "email", + "provider_id": "alice@example.com", + "identity_data": { "email": "alice@example.com" }, + "last_sign_in_at": "2025-03-01T08:15:00.000Z" + } + ] + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "aud": "authenticated", + "email": "bob.builder@example.com", + "email_confirmed_at": "2025-02-10T14:00:00.000Z", + "created_at": "2025-02-10T13:55:00.000Z", + "updated_at": "2025-02-10T14:00:00.000Z", + "last_sign_in_at": "2025-03-05T11:00:00.000Z", + "banned_until": null, + "user_metadata": { + "full_name": "Bob Builder", + "avatar_url": "https://cdn.example.com/avatar/bob.png" + }, + "app_metadata": { + "provider": "google", + "providers": ["google", "github"] + }, + "identities": [ + { + "id": "id-2g", + "user_id": "22222222-2222-2222-2222-222222222222", + "provider": "google", + "provider_id": "google-12345", + "identity_data": { "sub": "google-12345", "email": "bob.builder@example.com" } + }, + { + "id": "id-2h", + "user_id": "22222222-2222-2222-2222-222222222222", + "provider": "github", + "provider_id": "gh-98765" + } + ] + }, + { + "id": "33333333-3333-3333-3333-333333333333", + "aud": "authenticated", + "email": null, + "created_at": "2025-03-01T00:00:00.000Z", + "user_metadata": {}, + "app_metadata": {}, + "identities": [] + } + ] +} diff --git a/tests/providers/supabase/fixtures/admin-users-page-2.json b/tests/providers/supabase/fixtures/admin-users-page-2.json new file mode 100644 index 0000000..5d26f97 --- /dev/null +++ b/tests/providers/supabase/fixtures/admin-users-page-2.json @@ -0,0 +1,39 @@ +{ + "aud": "authenticated", + "users": [ + { + "id": "44444444-4444-4444-4444-444444444444", + "aud": "authenticated", + "email": "charlie@example.com", + "email_confirmed_at": "2025-04-01T09:00:00.000Z", + "created_at": "2025-04-01T08:55:00.000Z", + "updated_at": "2025-04-01T09:00:00.000Z", + "banned_until": "2099-12-31T00:00:00.000Z", + "user_metadata": { + "name": "Charlie Chaplin" + }, + "app_metadata": {}, + "identities": [] + }, + { + "id": "55555555-5555-5555-5555-555555555555", + "aud": "authenticated", + "email": "dana@example.com", + "email_confirmed_at": null, + "created_at": "2025-05-01T07:00:00.000Z", + "updated_at": "2025-05-01T07:00:00.000Z", + "last_sign_in_at": null, + "banned_until": null, + "user_metadata": {}, + "app_metadata": { "provider": "email" }, + "identities": [ + { + "id": "id-5e", + "user_id": "55555555-5555-5555-5555-555555555555", + "provider": "email", + "provider_id": "dana@example.com" + } + ] + } + ] +} diff --git a/tests/providers/supabase/fixtures/admin-users-page-3-empty.json b/tests/providers/supabase/fixtures/admin-users-page-3-empty.json new file mode 100644 index 0000000..4262840 --- /dev/null +++ b/tests/providers/supabase/fixtures/admin-users-page-3-empty.json @@ -0,0 +1,4 @@ +{ + "aud": "authenticated", + "users": [] +} From 7a87070d81075524814f6fa60f2482a3fb501fd0 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 19 May 2026 09:55:39 -0700 Subject: [PATCH 2/6] =?UTF-8?q?feat(supabase):=20Phase=202=20=E2=80=94=20b?= =?UTF-8?q?crypt=20passwords,=20MFA=20factors,=20SAML=20SSO=20via=20Postgr?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the second half of the Supabase Auth migration: bcrypt password hashes via a new merge-passwords-supabase command, TOTP MFA factors emitted into totp_secrets.csv for the existing enroll-totp pipeline, and SAML SSO configurations emitted into sso/saml_connections.csv via the same handoff helpers Auth0 and Cognito use. - SupabasePgClient: pg.Pool wrapper (max: 1) with statement_timeout (env-configurable via SUPABASE_PG_STATEMENT_TIMEOUT_MS), PgBouncer-port (6543) detection that surfaces a pooler warning, and SupabasePgError that hints at sslmode=require + direct-vs-pooler connection strings on connect failures. - exportMfaFactors: queries auth.mfa_factors joined to auth.users, filters to status='verified' + factor_type='totp', validates Base32 secrets. Skips non-TOTP factor types with warnings; emits an explicit warning when no verified factors are present so the operator knows MFA was empty (not silently broken). - exportSamlProviders: queries auth.saml_providers + auth.sso_providers + auth.sso_domains with COALESCE for the domains array. Reuses parseSamlMetadata from src/sso/saml-metadata.ts for metadata_xml parsing; falls back to a 10-second-timeout fetch of metadata_url when XML is not stored inline. Maps Supabase's attribute_mapping JSONB into the canonical SamlRow attribute columns. - mergeSupabasePasswords: mirrors Auth0's package-mode merger but adds three Supabase hardenings — provider-validates the manifest (refuses non-supabase packages), bcrypt-prefix-validates each hash ($2a/$2b/$2y only — argon2id, etc. are warned and skipped), and writes through a .tmp + fs.rename for atomic in-place updates. Patches manifest.metadata.passwordMerge with stats. - Package writer extended with writeTotpRecords and writeSamlConnections that route through the canonical writePackageCsvRows / writeSamlConnectionsCsv helpers, so Supabase's totp_secrets.csv and sso/saml_connections.csv match the existing schema consumed by enroll-totp and the WorkOS dashboard SAML upload. - exportSupabase wires the Postgres path behind an opt-in --db-url flag. When mfa/sso are requested without --db-url, the exporter logs a warning and still produces users.csv. Connection failures during the Postgres pass downgrade to warnings so the package isn't abandoned. - CLI: export-supabase grows --db-url, --totp-issuer, and accepts mfa+sso in --entities. New merge-passwords-supabase command (registered in cli/index.ts) accepts --package + --db-url, refuses non-supabase manifests. - Provider metadata flips mfa and sso entities to enabled (organizations stays disabled until Phase 3). - 24 new tests across 5 files (7 pg-client, 6 mfa-mapper, 5 sso-mapper, 6 password-merger, 3 integration); full suite 317/317 passing. Adds pg ^8 + @types/pg ^8 dependencies. - README expanded with --db-url + merge-passwords-supabase + enroll-totp + SAML dashboard handoff walkthrough. --- README.md | 41 ++- package-lock.json | 164 +++++++++++- package.json | 2 + src/cli/commands/export-supabase.ts | 32 ++- src/cli/commands/merge-passwords-supabase.ts | 54 ++++ src/cli/index.ts | 2 + .../supabase/__tests__/exporter.test.ts | 6 +- .../supabase/__tests__/mfa-mapper.test.ts | 98 +++++++ .../__tests__/password-merger.test.ts | 204 +++++++++++++++ .../supabase/__tests__/pg-client.test.ts | 127 +++++++++ .../supabase/__tests__/sso-mapper.test.ts | 149 +++++++++++ src/exporters/supabase/exporter.ts | 119 +++++++-- src/exporters/supabase/mfa-mapper.ts | 93 +++++++ src/exporters/supabase/package-writer.ts | 31 ++- src/exporters/supabase/password-merger.ts | 244 ++++++++++++++++++ src/exporters/supabase/pg-client.ts | 93 +++++++ src/exporters/supabase/sso-mapper.ts | 166 ++++++++++++ src/exporters/supabase/types.ts | 31 +++ src/providers/supabase/index.ts | 4 +- src/shared/types.ts | 7 + .../supabase/exporter-pg.integration.test.ts | 206 +++++++++++++++ 21 files changed, 1820 insertions(+), 53 deletions(-) create mode 100644 src/cli/commands/merge-passwords-supabase.ts create mode 100644 src/exporters/supabase/__tests__/mfa-mapper.test.ts create mode 100644 src/exporters/supabase/__tests__/password-merger.test.ts create mode 100644 src/exporters/supabase/__tests__/pg-client.test.ts create mode 100644 src/exporters/supabase/__tests__/sso-mapper.test.ts create mode 100644 src/exporters/supabase/mfa-mapper.ts create mode 100644 src/exporters/supabase/password-merger.ts create mode 100644 src/exporters/supabase/pg-client.ts create mode 100644 src/exporters/supabase/sso-mapper.ts create mode 100644 tests/providers/supabase/exporter-pg.integration.test.ts diff --git a/README.md b/README.md index 55062fd..4fe329f 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ Continue to [Validation](#validation), [Import](#importing-users), and [Post-Imp ## Migrating from Supabase Auth -> **Status — Phase 1**: users-only export via the Supabase Admin API. Password hashes, MFA TOTP factors, SAML SSO connections, and organization/role extraction land in later phases. +> **Status — Phase 2**: users + OAuth identities via the Admin API; bcrypt password hashes, TOTP MFA factors, and SAML SSO connections via direct Postgres. Organization/role extraction lands in Phase 3. ### 1. Set up Supabase credentials @@ -352,38 +352,59 @@ You will need: - **Project URL** — `https://.supabase.co` (Settings → API in the Supabase dashboard). - **Service Role Key** — the `service_role` JWT (Settings → API → Project API keys). This is _not_ the `anon` key; the service-role key is required for the Admin API. +- **Postgres connection string** (optional, but required for passwords/MFA/SSO) — the **direct** connection string (Settings → Database → Connection string). Use the direct connection on port `5432`, not the pooler on `6543`; PgBouncer in transaction mode breaks prepared statements. ```bash export SUPABASE_URL=https://your-project.supabase.co export SUPABASE_SERVICE_ROLE_KEY=eyJ... +export SUPABASE_DB_URL="postgresql://postgres:...@db.your-project.supabase.co:5432/postgres?sslmode=require" ``` -### 2. Export users +### 2. Export users (and MFA + SSO if `--db-url` is provided) ```bash workos-migrate export-supabase \ --url "$SUPABASE_URL" \ --service-role-key "$SUPABASE_SERVICE_ROLE_KEY" \ + --db-url "$SUPABASE_DB_URL" \ + --entities users,identities,mfa,sso \ --package \ --output-dir ./migration-supabase ``` -This produces a migration package directory with `users.csv`, `manifest.json`, `warnings.jsonl`, and `skipped_users.jsonl`. Linked OAuth identities (Google, GitHub, etc.) are preserved on each user row as `metadata.supabase_identities`. Users without an email and users currently banned (`banned_until` in the future) are skipped and recorded in `skipped_users.jsonl`. +This produces a migration package directory with `users.csv`, `totp_secrets.csv`, `sso/saml_connections.csv`, `manifest.json`, `warnings.jsonl`, and `skipped_users.jsonl`. Linked OAuth identities (Google, GitHub, etc.) are preserved on each user row as `metadata.supabase_identities`. Users without an email and users currently banned (`banned_until` in the future) are skipped and recorded in `skipped_users.jsonl`. -| Flag | Default | Description | -| --------------------- | ------- | ----------------------------------------------------------------- | -| `--rate-limit ` | 50 | Admin API requests per second | -| `--page-size ` | 1000 | Users per Admin API page | -| `--entities ` | `users` | Comma-separated entities (Phase 1: `users`, `identities` only) | +If you omit `--db-url`, the export still produces `users.csv` (with `metadata.supabase_identities`) but `mfa` and `sso` are skipped with warnings. -### 3. Validate, import, and post-import +| Flag | Default | Description | +| -------------------------- | ------------ | ---------------------------------------------------------------------------------------- | +| `--rate-limit ` | 50 | Admin API requests per second | +| `--page-size ` | 1000 | Users per Admin API page | +| `--entities ` | `users` | Comma-separated entities — `users`, `identities`, `mfa`, `sso` (Phase 2) | +| `--db-url ` | — | Postgres connection string (required for `mfa`/`sso`); also reads `SUPABASE_DB_URL` | +| `--totp-issuer ` | `Supabase` | Issuer label written into `totp_secrets.csv` | + +### 3. Merge bcrypt password hashes (optional) + +The Admin API does not expose `auth.users.encrypted_password`. Run `merge-passwords-supabase` to pull bcrypt hashes from Postgres and merge them into the package's `users.csv`: + +```bash +workos-migrate merge-passwords-supabase \ + --package ./migration-supabase \ + --db-url "$SUPABASE_DB_URL" +``` + +Only bcrypt prefixes (`$2a$`, `$2b$`, `$2y$`) are accepted — other algorithms are skipped and recorded in the manifest's warnings. + +### 4. Validate, import, and post-import ```bash workos-migrate validate --csv ./migration-supabase/users.csv workos-migrate import-package ./migration-supabase +workos-migrate enroll-totp --input ./migration-supabase/totp_secrets.csv ``` -Continue to [Validation](#validation), [Import](#importing-users), and [Post-Import](#post-import-totp-and-roles) below. +SAML SSO connections in `sso/saml_connections.csv` are imported manually via the WorkOS dashboard (Settings → SSO → Import). Continue to [Validation](#validation), [Import](#importing-users), and [Post-Import](#post-import-totp-and-roles) below. --- diff --git a/package-lock.json b/package-lock.json index 6b25974..a44a30b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "workos-migrations", + "name": "@workos/migrations", "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "workos-migrations", + "name": "@workos/migrations", "version": "2.1.1", "license": "MIT", "dependencies": { @@ -18,6 +18,7 @@ "csv-stringify": "^6.4.5", "dotenv": "^17.4.2", "fast-xml-parser": "^5.7.2", + "pg": "^8.21.0", "prompts": "^2.4.2" }, "bin": { @@ -28,6 +29,7 @@ "@types/cli-progress": "^3.11.6", "@types/jest": "^30.0.0", "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.1", @@ -3474,6 +3476,18 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prompts": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", @@ -7216,6 +7230,95 @@ "dev": true, "license": "ISC" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7315,6 +7418,45 @@ "node": ">=8" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7532,6 +7674,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -8253,6 +8404,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 884ca9b..79393b4 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "csv-stringify": "^6.4.5", "dotenv": "^17.4.2", "fast-xml-parser": "^5.7.2", + "pg": "^8.21.0", "prompts": "^2.4.2" }, "devDependencies": { @@ -53,6 +54,7 @@ "@types/cli-progress": "^3.11.6", "@types/jest": "^30.0.0", "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.1", diff --git a/src/cli/commands/export-supabase.ts b/src/cli/commands/export-supabase.ts index f586c3d..300eb91 100644 --- a/src/cli/commands/export-supabase.ts +++ b/src/cli/commands/export-supabase.ts @@ -3,7 +3,8 @@ import chalk from 'chalk'; import type { SupabaseExportOptions } from '../../shared/types.js'; import { exportSupabase } from '../../exporters/supabase/exporter.js'; -const PHASE_1_ALLOWED_ENTITIES = ['users', 'identities']; +const SUPPORTED_ENTITIES = ['users', 'identities', 'mfa', 'sso']; +const PG_ENTITIES = new Set(['mfa', 'sso']); function parseEntities(value: string | undefined): string[] { if (!value) return ['users']; @@ -19,18 +20,22 @@ export function registerExportSupabaseCommand(program: Command): void { .description('Export users from Supabase Auth to a WorkOS-compatible migration package') .requiredOption('--url ', 'Supabase project URL (e.g., https://xxxx.supabase.co)') .requiredOption('--service-role-key ', 'Supabase service role JWT') - .option('--package', 'Write a provider-neutral migration package (required in Phase 1)') + .option('--package', 'Write a provider-neutral migration package (required)') .option('--output-dir ', 'Output directory for the migration package') .option( '--entities ', - 'Comma-separated entities to export (users, identities)', + 'Comma-separated entities to export (users, identities, mfa, sso)', 'users', ) .option('--rate-limit ', 'Admin API requests per second', '50') .option('--page-size ', 'Admin API page size', '1000') .option( '--db-url ', - 'Postgres connection string (accepted for forward compatibility; unused in Phase 1)', + 'Postgres connection string (required for mfa and sso entities; can also be supplied via SUPABASE_DB_URL)', + ) + .option( + '--totp-issuer ', + 'Issuer label written into totp_secrets.csv (default: Supabase)', ) .option('--quiet', 'Suppress progress output') .action(async (opts) => { @@ -43,23 +48,32 @@ export function registerExportSupabaseCommand(program: Command): void { } const entities = parseEntities(opts.entities); - const unsupported = entities.filter((e) => !PHASE_1_ALLOWED_ENTITIES.includes(e)); + const unsupported = entities.filter((e) => !SUPPORTED_ENTITIES.includes(e)); if (unsupported.length > 0) { throw new Error( - `Phase 1 supports only ${PHASE_1_ALLOWED_ENTITIES.join(', ')}. Unsupported: ${unsupported.join( - ', ', - )}. MFA, SSO, and organizations land in later phases.`, + `Unsupported entities: ${unsupported.join(', ')}. Supported: ${SUPPORTED_ENTITIES.join(', ')}.`, + ); + } + + const dbUrl = opts.dbUrl ?? process.env.SUPABASE_DB_URL; + const pgRequested = entities.filter((e) => PG_ENTITIES.has(e)); + if (pgRequested.length > 0 && !dbUrl) { + console.error( + chalk.yellow( + `Warning: ${pgRequested.join(', ')} require --db-url; they will be skipped.`, + ), ); } const options: SupabaseExportOptions = { url: opts.url, serviceRoleKey: opts.serviceRoleKey, - dbUrl: opts.dbUrl, + dbUrl, outputDir: opts.outputDir, entities, rateLimit: parseInt(opts.rateLimit, 10), pageSize: parseInt(opts.pageSize, 10), + totpIssuer: opts.totpIssuer, quiet: opts.quiet ?? false, }; diff --git a/src/cli/commands/merge-passwords-supabase.ts b/src/cli/commands/merge-passwords-supabase.ts new file mode 100644 index 0000000..20d9818 --- /dev/null +++ b/src/cli/commands/merge-passwords-supabase.ts @@ -0,0 +1,54 @@ +import { Command } from 'commander'; +import fs from 'node:fs'; +import chalk from 'chalk'; +import { mergeSupabasePasswords } from '../../exporters/supabase/password-merger.js'; + +export function registerMergePasswordsSupabaseCommand(program: Command): void { + program + .command('merge-passwords-supabase') + .description('Merge bcrypt password hashes from Supabase Postgres into a migration package') + .requiredOption('--package ', 'Path to a Supabase migration package directory') + .option( + '--db-url ', + 'Supabase Postgres connection string (or set SUPABASE_DB_URL)', + ) + .option('--quiet', 'Suppress output messages') + .action(async (opts) => { + try { + const dbUrl = opts.dbUrl ?? process.env.SUPABASE_DB_URL; + if (!dbUrl) { + throw new Error('--db-url is required (or set SUPABASE_DB_URL).'); + } + if (!fs.existsSync(opts.package)) { + throw new Error(`Package directory not found: ${opts.package}`); + } + + const startTime = Date.now(); + if (!opts.quiet) { + console.log(chalk.blue(`Merging Supabase passwords into ${opts.package}...`)); + } + + const stats = await mergeSupabasePasswords({ + packageDir: opts.package, + dbUrl, + quiet: opts.quiet ?? false, + }); + + if (!opts.quiet) { + const duration = Date.now() - startTime; + console.log(chalk.green('\nPassword merge complete')); + console.log(` Total rows: ${stats.totalRows}`); + console.log(` Matched: ${stats.matched}`); + console.log(` Missing in DB: ${stats.missing}`); + console.log(` Unsupported algorithm: ${stats.unsupportedAlgo}`); + console.log(` Upload rows updated: ${stats.uploadRowsUpdated}`); + console.log(` Warnings: ${stats.warnings.length}`); + console.log(` Duration: ${duration}ms`); + console.log(` Package: ${opts.package}`); + } + } catch (error: unknown) { + console.error(chalk.red(`\nMerge failed: ${(error as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 305c53c..c8e99d9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import { registerExportAuth0Command } from './commands/export-auth0.js'; import { registerExportCognitoCommand } from './commands/export-cognito.js'; import { registerExportSupabaseCommand } from './commands/export-supabase.js'; import { registerMergePasswordsCommand } from './commands/merge-passwords.js'; +import { registerMergePasswordsSupabaseCommand } from './commands/merge-passwords-supabase.js'; import { registerTransformClerkCommand } from './commands/transform-clerk.js'; import { registerTransformFirebaseCommand } from './commands/transform-firebase.js'; import { registerAnalyzeCommand } from './commands/analyze.js'; @@ -27,6 +28,7 @@ registerExportAuth0Command(program); registerExportCognitoCommand(program); registerExportSupabaseCommand(program); registerMergePasswordsCommand(program); +registerMergePasswordsSupabaseCommand(program); registerTransformClerkCommand(program); registerTransformFirebaseCommand(program); registerAnalyzeCommand(program); diff --git a/src/exporters/supabase/__tests__/exporter.test.ts b/src/exporters/supabase/__tests__/exporter.test.ts index 1e9a388..d3d6ffe 100644 --- a/src/exporters/supabase/__tests__/exporter.test.ts +++ b/src/exporters/supabase/__tests__/exporter.test.ts @@ -103,18 +103,18 @@ describe('exportSupabase (end-to-end)', () => { expect(fs.existsSync(path.join(tmpDir, 'totp_secrets.csv'))).toBe(true); }); - it('throws when Phase 1 receives an unsupported entity', async () => { + it('throws when an unsupported entity is requested', async () => { await expect( exportSupabase({ url: 'https://demo.supabase.co', serviceRoleKey: 'sb-service-role-jwt', outputDir: tmpDir, - entities: ['users', 'mfa'], + entities: ['users', 'organizations'], rateLimit: 1000, pageSize: 100, quiet: true, }), - ).rejects.toThrow(/Phase 1 supports only/); + ).rejects.toThrow(/Unsupported entities for Supabase export/); }); it('throws a clear connection error on 401 from the Admin API', async () => { diff --git a/src/exporters/supabase/__tests__/mfa-mapper.test.ts b/src/exporters/supabase/__tests__/mfa-mapper.test.ts new file mode 100644 index 0000000..05e3e5a --- /dev/null +++ b/src/exporters/supabase/__tests__/mfa-mapper.test.ts @@ -0,0 +1,98 @@ +import type { PoolConfig } from 'pg'; +import { SupabasePgClient, type PgPoolLike } from '../pg-client.js'; +import { exportMfaFactors } from '../mfa-mapper.js'; +import type { SupabaseMfaFactorRow } from '../types.js'; + +function fakeClientWithRows(rows: SupabaseMfaFactorRow[] | (() => never)): SupabasePgClient { + const factory = (_config: PoolConfig): PgPoolLike => ({ + async query() { + if (typeof rows === 'function') { + rows(); + return { rows: [] }; + } + return { rows }; + }, + async end() {}, + }); + return new SupabasePgClient({ connectionString: 'postgresql://x', poolFactory: factory }); +} + +describe('exportMfaFactors', () => { + it('emits TotpRecord rows for verified TOTP factors only', async () => { + const pg = fakeClientWithRows([ + { + email: 'alice@example.com', + factor_type: 'totp', + secret: 'JBSWY3DPEHPK3PXP', + friendly_name: 'Phone', + status: 'verified', + }, + ]); + + const result = await exportMfaFactors(pg); + expect(result.records).toHaveLength(1); + expect(result.records[0]).toEqual({ + email: 'alice@example.com', + totpSecret: 'JBSWY3DPEHPK3PXP', + totpIssuer: 'Supabase', + totpUser: 'alice@example.com', + }); + expect(result.warnings).toHaveLength(0); + }); + + it('skips non-TOTP factor types with a warning per factor', async () => { + const pg = fakeClientWithRows([ + { + email: 'bob@example.com', + factor_type: 'webauthn', + secret: 'IRRELEVANT', + status: 'verified', + }, + { + email: 'carol@example.com', + factor_type: 'phone', + secret: 'IRRELEVANT', + status: 'verified', + }, + ]); + const result = await exportMfaFactors(pg); + expect(result.records).toHaveLength(0); + expect(result.skippedNonTotp).toBe(2); + expect(result.warnings.some((w) => /webauthn/.test(w))).toBe(true); + expect(result.warnings.some((w) => /phone/.test(w))).toBe(true); + }); + + it('skips factors whose secret is not valid Base32', async () => { + const pg = fakeClientWithRows([ + { email: 'dana@example.com', factor_type: 'totp', secret: 'not-base32!', status: 'verified' }, + ]); + const result = await exportMfaFactors(pg); + expect(result.records).toHaveLength(0); + expect(result.warnings[0]).toMatch(/Base32/); + }); + + it('uses the supplied totpIssuer when provided', async () => { + const pg = fakeClientWithRows([ + { email: 'alice@example.com', factor_type: 'totp', secret: 'JBSWY3DPEHPK3PXP', status: 'verified' }, + ]); + const result = await exportMfaFactors(pg, { totpIssuer: 'Acme' }); + expect(result.records[0].totpIssuer).toBe('Acme'); + }); + + it('returns empty records + warning when auth.mfa_factors does not exist', async () => { + const pg = fakeClientWithRows((() => { + throw new Error('relation "auth.mfa_factors" does not exist'); + }) as () => never); + const result = await exportMfaFactors(pg); + expect(result.records).toHaveLength(0); + expect(result.warnings[0]).toMatch(/skipped/i); + }); + + it('emits an extra warning when query returned rows but emitted nothing', async () => { + const pg = fakeClientWithRows([ + { email: 'webauthn-only@example.com', factor_type: 'webauthn', secret: 'x', status: 'verified' }, + ]); + const result = await exportMfaFactors(pg); + expect(result.warnings.some((w) => /emitted 0/.test(w))).toBe(true); + }); +}); diff --git a/src/exporters/supabase/__tests__/password-merger.test.ts b/src/exporters/supabase/__tests__/password-merger.test.ts new file mode 100644 index 0000000..f49e6b4 --- /dev/null +++ b/src/exporters/supabase/__tests__/password-merger.test.ts @@ -0,0 +1,204 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { mergeSupabasePasswords } from '../password-merger.js'; +import type { SupabasePgQueryClient } from '../pg-client.js'; + +interface PasswordRow { + email: string; + encrypted_password: string; +} + +function fakeClient(rows: PasswordRow[]): SupabasePgQueryClient { + return { + async testConnection() {}, + async query(_sql: string, params?: unknown[]): Promise { + const emails = Array.isArray(params?.[0]) ? (params[0] as string[]) : []; + const matched = rows.filter((row) => emails.includes(row.email.toLowerCase())); + return matched as unknown as T[]; + }, + async close() {}, + }; +} + +interface UserSeed { + email: string; + external_id: string; +} + +async function setupPackage( + rootDir: string, + users: UserSeed[], + provider: string = 'supabase', + options: { withoutUsersCsv?: boolean } = {}, +): Promise { + const manifest = { + schemaVersion: 1, + provider, + generatedAt: new Date().toISOString(), + entitiesRequested: ['users'], + entitiesExported: { users: users.length }, + files: {}, + importability: {}, + secretsRedacted: true, + warnings: [], + }; + await fsp.writeFile(path.join(rootDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8'); + + if (options.withoutUsersCsv) return; + + const header = + 'email,password,password_hash,password_hash_type,first_name,last_name,email_verified,external_id,metadata,org_id,org_external_id,org_name,role_slugs\n'; + const body = users.map((u) => `${u.email},,,,,,true,${u.external_id},{},,,,`).join('\n'); + await fsp.writeFile(path.join(rootDir, 'users.csv'), header + body + (body ? '\n' : ''), 'utf-8'); +} + +async function readUsersCsv(rootDir: string): Promise[]> { + const raw = await fsp.readFile(path.join(rootDir, 'users.csv'), 'utf-8'); + const [headerLine, ...lines] = raw.trim().split('\n'); + const headers = headerLine.split(','); + return lines.map((line) => { + const cols = line.split(','); + const row: Record = {}; + headers.forEach((h, i) => { + row[h] = cols[i] ?? ''; + }); + return row; + }); +} + +describe('mergeSupabasePasswords', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supabase-merge-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('merges bcrypt hashes into users.csv and patches the manifest', async () => { + await setupPackage(tmpDir, [ + { email: 'alice@example.com', external_id: 'u1' }, + { email: 'bob@example.com', external_id: 'u2' }, + { email: 'no-match@example.com', external_id: 'u3' }, + ]); + + const stats = await mergeSupabasePasswords({ + packageDir: tmpDir, + dbUrl: 'postgresql://x', + quiet: true, + clientFactory: () => + fakeClient([ + { email: 'alice@example.com', encrypted_password: '$2a$10$alicebcrypt' }, + { email: 'bob@example.com', encrypted_password: '$2b$12$bobbcrypt' }, + ]), + }); + + expect(stats.totalRows).toBe(3); + expect(stats.matched).toBe(2); + expect(stats.missing).toBe(1); + expect(stats.unsupportedAlgo).toBe(0); + + const rows = await readUsersCsv(tmpDir); + expect(rows.find((r) => r.email === 'alice@example.com')?.password_hash).toBe('$2a$10$alicebcrypt'); + expect(rows.find((r) => r.email === 'alice@example.com')?.password_hash_type).toBe('bcrypt'); + expect(rows.find((r) => r.email === 'bob@example.com')?.password_hash).toBe('$2b$12$bobbcrypt'); + + const manifest = JSON.parse(await fsp.readFile(path.join(tmpDir, 'manifest.json'), 'utf-8')); + expect(manifest.metadata.passwordMerge.matched).toBe(2); + expect(manifest.metadata.passwordMerge.missing).toBe(1); + }); + + it('accepts $2y$ bcrypt prefix and rejects non-bcrypt with a warning', async () => { + await setupPackage(tmpDir, [ + { email: 'alice@example.com', external_id: 'u1' }, + { email: 'argon@example.com', external_id: 'u2' }, + ]); + + const stats = await mergeSupabasePasswords({ + packageDir: tmpDir, + dbUrl: 'postgresql://x', + quiet: true, + clientFactory: () => + fakeClient([ + { email: 'alice@example.com', encrypted_password: '$2y$10$alicebcrypt' }, + { email: 'argon@example.com', encrypted_password: '$argon2id$v=19$m=4096,t=3,p=1$xxxx$yyyy' }, + ]), + }); + + expect(stats.matched).toBe(1); + expect(stats.unsupportedAlgo).toBe(1); + expect(stats.warnings.some((w) => /unsupported algorithm/.test(w))).toBe(true); + }); + + it('is idempotent: running twice produces identical CSV', async () => { + await setupPackage(tmpDir, [{ email: 'alice@example.com', external_id: 'u1' }]); + const factory = () => fakeClient([{ email: 'alice@example.com', encrypted_password: '$2a$10$alicebcrypt' }]); + + await mergeSupabasePasswords({ packageDir: tmpDir, dbUrl: 'postgresql://x', quiet: true, clientFactory: factory }); + const first = await fsp.readFile(path.join(tmpDir, 'users.csv'), 'utf-8'); + + await mergeSupabasePasswords({ packageDir: tmpDir, dbUrl: 'postgresql://x', quiet: true, clientFactory: factory }); + const second = await fsp.readFile(path.join(tmpDir, 'users.csv'), 'utf-8'); + expect(second).toBe(first); + }); + + it('refuses to operate on a non-Supabase package', async () => { + await setupPackage(tmpDir, [{ email: 'alice@example.com', external_id: 'u1' }], 'auth0'); + + await expect( + mergeSupabasePasswords({ + packageDir: tmpDir, + dbUrl: 'postgresql://x', + quiet: true, + clientFactory: () => fakeClient([]), + }), + ).rejects.toThrow(/provider "auth0"/); + }); + + it('throws when users.csv is missing', async () => { + await setupPackage(tmpDir, [], 'supabase', { withoutUsersCsv: true }); + + await expect( + mergeSupabasePasswords({ + packageDir: tmpDir, + dbUrl: 'postgresql://x', + quiet: true, + clientFactory: () => fakeClient([]), + }), + ).rejects.toThrow(/users\.csv not found/); + }); + + it('batches large email lists into chunks of 1000', async () => { + const seeds: UserSeed[] = []; + for (let i = 0; i < 1500; i++) { + seeds.push({ email: `user${i}@example.com`, external_id: `u${i}` }); + } + await setupPackage(tmpDir, seeds); + + let batchCount = 0; + const stats = await mergeSupabasePasswords({ + packageDir: tmpDir, + dbUrl: 'postgresql://x', + quiet: true, + clientFactory: () => ({ + async testConnection() {}, + async query(_sql: string, params?: unknown[]): Promise { + batchCount++; + const emails = Array.isArray(params?.[0]) ? (params[0] as string[]) : []; + return emails.map((email) => ({ + email, + encrypted_password: '$2b$10$generic', + })) as unknown as T[]; + }, + async close() {}, + }), + }); + + expect(stats.matched).toBe(1500); + expect(batchCount).toBe(2); // 1500 emails / 1000 per batch + }); +}); diff --git a/src/exporters/supabase/__tests__/pg-client.test.ts b/src/exporters/supabase/__tests__/pg-client.test.ts new file mode 100644 index 0000000..576ed6e --- /dev/null +++ b/src/exporters/supabase/__tests__/pg-client.test.ts @@ -0,0 +1,127 @@ +import { jest } from '@jest/globals'; +import type { PoolConfig } from 'pg'; +import { SupabasePgClient, type PgPoolLike } from '../pg-client.js'; +import { SupabasePgError } from '../types.js'; + +interface FakePool extends PgPoolLike { + config: PoolConfig; + queries: Array<{ sql: string; params?: unknown[] }>; + closed: boolean; +} + +function createFakePoolFactory(queryImpl?: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>): { + factory: (config: PoolConfig) => PgPoolLike; + pools: FakePool[]; +} { + const pools: FakePool[] = []; + const factory = (config: PoolConfig): PgPoolLike => { + const pool: FakePool = { + config, + queries: [], + closed: false, + async query(sql: string, params?: unknown[]) { + pool.queries.push({ sql, params }); + if (queryImpl) return queryImpl(sql, params); + return { rows: [] }; + }, + async end() { + pool.closed = true; + }, + }; + pools.push(pool); + return pool; + }; + return { factory, pools }; +} + +describe('SupabasePgClient', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('passes connectionString, max: 1, and statement_timeout to the pool factory', () => { + const { factory, pools } = createFakePoolFactory(); + new SupabasePgClient({ + connectionString: 'postgresql://user:pw@db.example.com:5432/postgres', + statementTimeoutMs: 12_000, + poolFactory: factory, + }); + + expect(pools).toHaveLength(1); + expect(pools[0].config.max).toBe(1); + expect(pools[0].config.statement_timeout).toBe(12_000); + expect(pools[0].config.connectionString).toBe('postgresql://user:pw@db.example.com:5432/postgres'); + }); + + it('uses a 30s default statement timeout when not specified', () => { + const { factory, pools } = createFakePoolFactory(); + new SupabasePgClient({ + connectionString: 'postgresql://user:pw@db.example.com:5432/postgres', + poolFactory: factory, + }); + expect(pools[0].config.statement_timeout).toBe(30_000); + }); + + it('exposes a pooler warning when the connection string targets port 6543', () => { + const { factory } = createFakePoolFactory(); + const client = new SupabasePgClient({ + connectionString: 'postgresql://user:pw@db.example.com:6543/postgres', + poolFactory: factory, + }); + expect(client.poolerWarning).toMatch(/PgBouncer/); + }); + + it('returns undefined poolerWarning for direct (5432) connections', () => { + const { factory } = createFakePoolFactory(); + const client = new SupabasePgClient({ + connectionString: 'postgresql://user:pw@db.example.com:5432/postgres', + poolFactory: factory, + }); + expect(client.poolerWarning).toBeUndefined(); + }); + + it('runs SELECT 1 in testConnection() and surfaces a SupabasePgError with hint on failure', async () => { + const { factory } = createFakePoolFactory(async () => { + throw new Error('SSL connection required'); + }); + const client = new SupabasePgClient({ + connectionString: 'postgresql://user:pw@db.example.com:5432/postgres', + poolFactory: factory, + }); + + let caught: unknown; + try { + await client.testConnection(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(SupabasePgError); + expect((caught as SupabasePgError).hint).toMatch(/sslmode=require/); + }); + + it('forwards query() params to the pool and returns rows', async () => { + const { factory, pools } = createFakePoolFactory(async (_sql, params) => ({ + rows: [{ email: 'a@example.com', encrypted_password: '$2b$10$abc' }], + lastParams: params, + } as unknown as { rows: unknown[] })); + const client = new SupabasePgClient({ + connectionString: 'postgresql://user:pw@db.example.com:5432/postgres', + poolFactory: factory, + }); + + const rows = await client.query<{ email: string }>('SELECT 1 WHERE x = ANY($1)', [['a@example.com']]); + expect(rows).toHaveLength(1); + expect(rows[0].email).toBe('a@example.com'); + expect(pools[0].queries[0].params).toEqual([['a@example.com']]); + }); + + it('close() ends the underlying pool', async () => { + const { factory, pools } = createFakePoolFactory(); + const client = new SupabasePgClient({ + connectionString: 'postgresql://user:pw@db.example.com:5432/postgres', + poolFactory: factory, + }); + await client.close(); + expect(pools[0].closed).toBe(true); + }); +}); diff --git a/src/exporters/supabase/__tests__/sso-mapper.test.ts b/src/exporters/supabase/__tests__/sso-mapper.test.ts new file mode 100644 index 0000000..40928f2 --- /dev/null +++ b/src/exporters/supabase/__tests__/sso-mapper.test.ts @@ -0,0 +1,149 @@ +import { jest } from '@jest/globals'; +import type { PoolConfig } from 'pg'; +import { SupabasePgClient, type PgPoolLike } from '../pg-client.js'; +import { exportSamlProviders } from '../sso-mapper.js'; +import type { SupabaseSamlProviderRow } from '../types.js'; + +const SAMPLE_METADATA = ` + + + + + + MIIDCERT + + + + + +`; + +function fakeClientWithRows(rows: SupabaseSamlProviderRow[] | (() => never)): SupabasePgClient { + const factory = (_config: PoolConfig): PgPoolLike => ({ + async query() { + if (typeof rows === 'function') { + rows(); + return { rows: [] }; + } + return { rows }; + }, + async end() {}, + }); + return new SupabasePgClient({ connectionString: 'postgresql://x', poolFactory: factory }); +} + +describe('exportSamlProviders', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('parses metadata_xml into a SamlRow with entity id, sso url, and cert', async () => { + const pg = fakeClientWithRows([ + { + id: 'saml-1', + sso_provider_id: 'ssp-1', + entity_id: null, + metadata_xml: SAMPLE_METADATA, + metadata_url: null, + attribute_mapping: { keys: { email: 'urn:oid:email' } }, + resource_id: 'ext-org-1', + domains: ['example.com', 'example.org'], + }, + ]); + const result = await exportSamlProviders(pg); + expect(result.rows).toHaveLength(1); + const row = result.rows[0]; + expect(row.idpEntityId).toBe('https://idp.example.com/saml'); + expect(row.idpUrl).toBe('https://idp.example.com/sso'); + expect(row.x509Cert).toBe('MIIDCERT'); + expect(row.domains).toBe('example.com,example.org'); + expect(row.emailAttribute).toBe('urn:oid:email'); + expect(row.organizationExternalId).toBe('ext-org-1'); + expect(row.importedId).toBe('saml-1'); + }); + + it('fetches metadata_url with timeout when metadata_xml is empty', async () => { + const fetchMock = jest.fn() as jest.MockedFunction; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => SAMPLE_METADATA, + } as unknown as Response); + global.fetch = fetchMock; + + const pg = fakeClientWithRows([ + { + id: 'saml-2', + sso_provider_id: 'ssp-2', + entity_id: null, + metadata_xml: null, + metadata_url: 'https://idp.example.com/metadata', + attribute_mapping: null, + resource_id: 'ext-org-2', + domains: [], + }, + ]); + const result = await exportSamlProviders(pg); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://idp.example.com/metadata', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.rows[0].idpUrl).toBe('https://idp.example.com/sso'); + expect(result.warnings.some((w) => /no email attribute/.test(w))).toBe(true); + expect(result.warnings.some((w) => /no domains/.test(w))).toBe(true); + }); + + it('emits a warning when metadata fetch fails', async () => { + const fetchMock = jest.fn() as jest.MockedFunction; + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'server error', + } as unknown as Response); + global.fetch = fetchMock; + + const pg = fakeClientWithRows([ + { + id: 'saml-3', + sso_provider_id: 'ssp-3', + entity_id: 'fallback-entity-id', + metadata_xml: null, + metadata_url: 'https://idp.example.com/metadata', + attribute_mapping: null, + resource_id: null, + domains: [], + }, + ]); + const result = await exportSamlProviders(pg); + expect(result.warnings.some((w) => /HTTP 500/.test(w))).toBe(true); + expect(result.rows[0].idpEntityId).toBe('fallback-entity-id'); + }); + + it('returns empty rows + warning when auth.saml_providers does not exist', async () => { + const pg = fakeClientWithRows((() => { + throw new Error('relation "auth.saml_providers" does not exist'); + }) as () => never); + const result = await exportSamlProviders(pg); + expect(result.rows).toHaveLength(0); + expect(result.warnings[0]).toMatch(/skipped/i); + }); + + it('reads attribute_mapping directly (not under .keys)', async () => { + const pg = fakeClientWithRows([ + { + id: 'saml-4', + sso_provider_id: 'ssp-4', + entity_id: null, + metadata_xml: SAMPLE_METADATA, + metadata_url: null, + attribute_mapping: { email: 'mail', first_name: 'givenName' }, + resource_id: 'ext-org-4', + domains: ['example.com'], + }, + ]); + const result = await exportSamlProviders(pg); + expect(result.rows[0].emailAttribute).toBe('mail'); + expect(result.rows[0].firstNameAttribute).toBe('givenName'); + }); +}); diff --git a/src/exporters/supabase/exporter.ts b/src/exporters/supabase/exporter.ts index 2ead9e1..0a81be8 100644 --- a/src/exporters/supabase/exporter.ts +++ b/src/exporters/supabase/exporter.ts @@ -1,23 +1,31 @@ import type { ExportSummary, SupabaseExportOptions } from '../../shared/types.js'; import * as logger from '../../shared/logger.js'; import { SupabaseAdminClient } from './admin-api-client.js'; +import { SupabasePgClient } from './pg-client.js'; import { mapSupabaseUser } from './user-mapper.js'; -import { openSupabasePackage } from './package-writer.js'; +import { exportMfaFactors } from './mfa-mapper.js'; +import { exportSamlProviders } from './sso-mapper.js'; +import { openSupabasePackage, type SupabaseWriterContext } from './package-writer.js'; +import type { SupabasePgQueryClient } from './pg-client.js'; -const PHASE_1_ALLOWED_ENTITIES = new Set(['users', 'identities']); +const SUPPORTED_ENTITIES = new Set(['users', 'identities', 'mfa', 'sso']); +const PG_ENTITIES = new Set(['mfa', 'sso']); -export async function exportSupabase(options: SupabaseExportOptions): Promise { +export interface ExportSupabaseInternal extends SupabaseExportOptions { + /** Test seam — replace the Postgres client used for mfa/sso exports. */ + pgClientFactory?: (dbUrl: string) => SupabasePgQueryClient; +} + +export async function exportSupabase(options: ExportSupabaseInternal): Promise { if (!options.outputDir) { throw new Error('--output-dir is required for Supabase package export'); } const requested = options.entities.length > 0 ? options.entities : ['users']; - const unsupported = requested.filter((entity) => !PHASE_1_ALLOWED_ENTITIES.has(entity)); + const unsupported = requested.filter((entity) => !SUPPORTED_ENTITIES.has(entity)); if (unsupported.length > 0) { throw new Error( - `Phase 1 supports only 'users' and 'identities' entities; unsupported: ${unsupported.join( - ', ', - )}. MFA, SSO, and organizations land in later phases.`, + `Unsupported entities for Supabase export: ${unsupported.join(', ')}. Supported in Phase 2: users, identities, mfa, sso.`, ); } @@ -40,6 +48,38 @@ export async function exportSupabase(options: SupabaseExportOptions): Promise 0) logger.info(` TOTP factors: ${pkg.stats.totpExported}`); + if (pkg.stats.samlExported > 0) logger.info(` SAML providers: ${pkg.stats.samlExported}`); + logger.info(` Warnings: ${pkg.stats.warnings.length}`); + logger.info(` Duration: ${duration}ms`); + logger.info(` Output: ${pkg.rootDir}`); + } + + return { + totalUsers: pkg.stats.exported, + totalOrgs: 0, + skippedUsers: pkg.stats.skipped, + duration, + }; +} + +async function exportUsers( + client: SupabaseAdminClient, + pkg: SupabaseWriterContext, + options: SupabaseExportOptions, +): Promise { const iterator = client.listUsers({ onDuplicate: (userId) => { pkg.stats.warnings.push(`Duplicate user.id encountered during pagination: ${userId}`); @@ -72,25 +112,56 @@ export async function exportSupabase(options: SupabaseExportOptions): Promise { + const pgRequested = requested.filter((entity) => PG_ENTITIES.has(entity)); + if (pgRequested.length === 0) return; + + if (!options.dbUrl) { + pkg.stats.warnings.push( + `Requested entities ${pgRequested.join(', ')} require --db-url; skipping (users.csv still produced).`, + ); + return; + } - const duration = Date.now() - startTime; + if (!options.quiet) logger.info('Connecting to Supabase Postgres...'); + + const pg: SupabasePgQueryClient = options.pgClientFactory + ? options.pgClientFactory(options.dbUrl) + : new SupabasePgClient({ connectionString: options.dbUrl }); + if (pg.poolerWarning) pkg.stats.warnings.push(pg.poolerWarning); + + try { + try { + await pg.testConnection(); + } catch (error: unknown) { + pkg.stats.warnings.push( + `Supabase Postgres connection failed: ${(error as Error).message}. mfa/sso export skipped.`, + ); + return; + } - if (!options.quiet) { - logger.success(`\nExport complete`); - logger.info(` Total fetched: ${pkg.stats.totalFetched}`); - logger.info(` Exported: ${pkg.stats.exported}`); - logger.info(` Skipped: ${pkg.stats.skipped}`); - logger.info(` Warnings: ${pkg.stats.warnings.length}`); - logger.info(` Duration: ${duration}ms`); - logger.info(` Output: ${pkg.rootDir}`); - } + if (!options.quiet) logger.success('Connected to Supabase Postgres'); - return { - totalUsers: pkg.stats.exported, - totalOrgs: 0, - skippedUsers: pkg.stats.skipped, - duration, - }; + if (requested.includes('mfa')) { + if (!options.quiet) logger.info(' Exporting MFA factors...'); + const mfa = await exportMfaFactors(pg, { totpIssuer: options.totpIssuer }); + if (mfa.warnings.length > 0) pkg.stats.warnings.push(...mfa.warnings); + await pkg.writeTotpRecords(mfa.records); + } + + if (requested.includes('sso')) { + if (!options.quiet) logger.info(' Exporting SAML SSO providers...'); + const saml = await exportSamlProviders(pg); + if (saml.warnings.length > 0) pkg.stats.warnings.push(...saml.warnings); + await pkg.writeSamlConnections(saml.rows); + } + } finally { + await pg.close(); + } } diff --git a/src/exporters/supabase/mfa-mapper.ts b/src/exporters/supabase/mfa-mapper.ts new file mode 100644 index 0000000..4aa2628 --- /dev/null +++ b/src/exporters/supabase/mfa-mapper.ts @@ -0,0 +1,93 @@ +import type { TotpRecord } from '../../shared/types.js'; +import type { SupabasePgQueryClient } from './pg-client.js'; +import type { SupabaseMfaFactorRow } from './types.js'; + +const MFA_QUERY = ` + SELECT u.email, + f.factor_type, + f.secret, + f.friendly_name, + f.status + FROM auth.mfa_factors f + JOIN auth.users u ON u.id = f.user_id + WHERE f.status = 'verified' + ORDER BY u.email +`; + +const BASE32_RE = /^[A-Z2-7]+=*$/i; + +export interface MfaExportResult { + records: TotpRecord[]; + warnings: string[]; + skippedNonTotp: number; +} + +export interface MfaExportOptions { + totpIssuer?: string; +} + +export async function exportMfaFactors( + pg: SupabasePgQueryClient, + options: MfaExportOptions = {}, +): Promise { + const issuer = options.totpIssuer ?? 'Supabase'; + const result: MfaExportResult = { records: [], warnings: [], skippedNonTotp: 0 }; + + let rows: SupabaseMfaFactorRow[]; + try { + rows = await pg.query(MFA_QUERY); + } catch (error: unknown) { + const message = (error as Error).message ?? 'unknown error'; + if (isMissingTableError(message)) { + result.warnings.push( + 'auth.mfa_factors table not found (older GoTrue schema?); MFA export skipped.', + ); + return result; + } + throw error; + } + + for (const row of rows) { + if (!row.email?.trim()) { + result.warnings.push('Skipping MFA factor with empty email'); + continue; + } + + if (row.factor_type !== 'totp') { + result.skippedNonTotp++; + result.warnings.push( + `Skipping non-TOTP factor (${row.factor_type}) for ${row.email}; only TOTP factors are migrated`, + ); + continue; + } + + const secret = row.secret?.trim(); + if (!secret || !BASE32_RE.test(secret)) { + result.warnings.push( + `Skipping TOTP factor for ${row.email}: secret is not a valid Base32 string`, + ); + continue; + } + + result.records.push({ + email: row.email.trim(), + totpSecret: secret, + totpIssuer: issuer, + totpUser: row.email.trim(), + }); + } + + if (rows.length === 0) { + result.warnings.push('No verified TOTP MFA factors found in auth.mfa_factors.'); + } else if (result.records.length === 0) { + result.warnings.push( + `Queried ${rows.length} MFA factor(s) but emitted 0 (all skipped due to type/format).`, + ); + } + + return result; +} + +function isMissingTableError(message: string): boolean { + return /does not exist/i.test(message) && /mfa_factors|auth\./i.test(message); +} diff --git a/src/exporters/supabase/package-writer.ts b/src/exporters/supabase/package-writer.ts index 8b71160..2fe88d8 100644 --- a/src/exporters/supabase/package-writer.ts +++ b/src/exporters/supabase/package-writer.ts @@ -2,21 +2,27 @@ import path from 'node:path'; import { createCSVWriter } from '../../shared/csv-utils.js'; import { USER_CSV_HEADERS, + TOTP_SECRET_CSV_HEADERS, createMigrationPackageManifest, } from '../../package/manifest.js'; import { createEmptyPackageFiles, getPackageFilePath, writeMigrationPackageManifest, + writePackageCsvRows, writePackageJsonlRecords, } from '../../package/writer.js'; -import type { CSVRow } from '../../shared/types.js'; +import type { CsvRow as PackageCsvRow } from '../../package/writer.js'; +import { writeSamlConnectionsCsv, type SamlRowInput } from '../../sso/handoff.js'; +import type { CSVRow, TotpRecord } from '../../shared/types.js'; import type { SupabaseExportStats } from './types.js'; export interface SupabaseWriterContext { rootDir: string; stats: SupabaseExportStats; writeUser: (row: CSVRow) => void; + writeTotpRecords: (records: TotpRecord[]) => Promise; + writeSamlConnections: (rows: SamlRowInput[]) => Promise; finalize: (options: { url: string; entitiesRequested: string[] }) => Promise; } @@ -45,6 +51,8 @@ export async function openSupabasePackage(outputDir: string): Promise ({ + email: record.email, + totp_secret: record.totpSecret, + totp_issuer: record.totpIssuer ?? '', + totp_user: record.totpUser ?? record.email, + })); + await writePackageCsvRows(rootDir, 'totpSecrets', rows, TOTP_SECRET_CSV_HEADERS); + stats.totpExported += records.length; + }, + async writeSamlConnections(rows: SamlRowInput[]) { + if (rows.length === 0) return; + const filePath = getPackageFilePath(rootDir, 'samlConnections'); + await writeSamlConnectionsCsv(filePath, rows); + stats.samlExported += rows.length; + }, async finalize(options) { await usersWriter.end(); @@ -71,8 +96,8 @@ export async function openSupabasePackage(outputDir: string): Promise SupabasePgQueryClient; +} + +export async function mergeSupabasePasswords( + options: MergeSupabasePasswordsInternal, +): Promise { + const packageDir = path.resolve(options.packageDir); + const usersCsvPath = getPackageFilePath(packageDir, 'users'); + const uploadUsersCsvPath = getPackageFilePath(packageDir, 'uploadUsers'); + const manifestPath = getPackageFilePath(packageDir, 'manifest'); + + await assertSupabasePackage(manifestPath); + + if (!(await pathExists(usersCsvPath))) { + throw new Error( + `Package users.csv not found at ${usersCsvPath}. Run export-supabase --package first.`, + ); + } + + const usersHeaders = [...MIGRATION_PACKAGE_CSV_HEADERS.users]; + const usersRows = await readCsvWithFixedHeaders(usersCsvPath, usersHeaders); + + const stats: PasswordMergeStats = { + totalRows: usersRows.length, + matched: 0, + missing: 0, + unsupportedAlgo: 0, + uploadRowsUpdated: 0, + warnings: [], + }; + + if (usersRows.length === 0) { + return stats; + } + + const pg: SupabasePgQueryClient = options.clientFactory + ? options.clientFactory(options.dbUrl) + : new SupabasePgClient({ connectionString: options.dbUrl }); + if (pg.poolerWarning) stats.warnings.push(pg.poolerWarning); + + const passwordByEmail = new Map(); + try { + await pg.testConnection(); + + const emails = uniqueEmails(usersRows); + for (let i = 0; i < emails.length; i += EMAIL_BATCH_SIZE) { + const batch = emails.slice(i, i + EMAIL_BATCH_SIZE); + const rows = await pg.query( + 'SELECT email, encrypted_password FROM auth.users WHERE email = ANY($1)', + [batch], + ); + for (const row of rows) { + if (row.email && row.encrypted_password) { + passwordByEmail.set(row.email.toLowerCase(), row.encrypted_password); + } + } + } + } finally { + await pg.close(); + } + + const hashByExternalId = new Map(); + + for (const row of usersRows) { + const email = row.email?.toLowerCase().trim(); + if (!email) { + stats.warnings.push('Skipping password merge for row with empty email column'); + continue; + } + const candidate = passwordByEmail.get(email); + if (!candidate) { + stats.missing++; + continue; + } + + if (!BCRYPT_RE.test(candidate)) { + stats.unsupportedAlgo++; + stats.warnings.push( + `Skipped password hash for ${email}: unsupported algorithm (prefix ${candidate.slice(0, 4)}); only bcrypt is supported by WorkOS imports.`, + ); + continue; + } + + row.password_hash = candidate; + row.password_hash_type = 'bcrypt'; + stats.matched++; + + if (row.external_id) hashByExternalId.set(row.external_id, candidate); + } + + await writeCsvAtomic(usersCsvPath, usersHeaders, usersRows); + + if (await pathExists(uploadUsersCsvPath)) { + const uploadHeaders = [...MIGRATION_PACKAGE_CSV_HEADERS.uploadUsers]; + const uploadRows = await readCsvWithFixedHeaders(uploadUsersCsvPath, uploadHeaders); + for (const uploadRow of uploadRows) { + const userId = uploadRow.user_id; + const hash = userId ? hashByExternalId.get(userId) : undefined; + if (hash) { + uploadRow.password_hash = hash; + stats.uploadRowsUpdated++; + } + } + await writeCsvAtomic(uploadUsersCsvPath, uploadHeaders, uploadRows); + } + + if (await pathExists(manifestPath)) { + await updateManifest(manifestPath, stats); + } + + return stats; +} + +async function assertSupabasePackage(manifestPath: string): Promise { + if (!(await pathExists(manifestPath))) { + throw new Error( + `Manifest not found at ${manifestPath}. Run export-supabase --package first.`, + ); + } + const raw = await fsp.readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw) as MigrationPackageManifest; + if (manifest.provider !== 'supabase') { + throw new Error( + `Refusing to operate on package with provider "${manifest.provider}". merge-passwords-supabase only supports Supabase packages.`, + ); + } +} + +function uniqueEmails(rows: Record[]): string[] { + const set = new Set(); + for (const row of rows) { + const email = row.email?.toLowerCase().trim(); + if (email) set.add(email); + } + return Array.from(set); +} + +async function pathExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +async function readCsvWithFixedHeaders( + filePath: string, + headers: string[], +): Promise[]> { + const rows: Record[] = []; + await new Promise((resolve, reject) => { + const parser = parse({ + columns: true, + skip_empty_lines: true, + relax_column_count: true, + }); + createReadStream(filePath) + .pipe(parser) + .on('data', (row: Record) => { + const normalized: Record = {}; + for (const header of headers) { + normalized[header] = row[header] ?? ''; + } + rows.push(normalized); + }) + .on('end', resolve) + .on('error', reject); + }); + return rows; +} + +async function writeCsvAtomic( + filePath: string, + headers: string[], + rows: Record[], +): Promise { + const tmpPath = `${filePath}.tmp-${process.pid}`; + await new Promise((resolve, reject) => { + const stringifier = stringify({ header: true, columns: headers }); + const out = createWriteStream(tmpPath); + stringifier.pipe(out).on('finish', resolve).on('error', reject); + for (const row of rows) { + stringifier.write(row); + } + stringifier.end(); + }); + await fsp.rename(tmpPath, filePath); +} + +async function updateManifest( + manifestPath: string, + stats: PasswordMergeStats, +): Promise { + const raw = await fsp.readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(raw) as MigrationPackageManifest; + manifest.metadata = { + ...(manifest.metadata ?? {}), + passwordMerge: { + mergedAt: new Date().toISOString(), + matched: stats.matched, + missing: stats.missing, + unsupportedAlgo: stats.unsupportedAlgo, + uploadRowsUpdated: stats.uploadRowsUpdated, + }, + }; + if (stats.warnings.length > 0) { + manifest.warnings = [...(manifest.warnings ?? []), ...stats.warnings]; + } + const rootDir = path.dirname(manifestPath); + await writeMigrationPackageManifest(rootDir, manifest); +} diff --git a/src/exporters/supabase/pg-client.ts b/src/exporters/supabase/pg-client.ts new file mode 100644 index 0000000..d22fef7 --- /dev/null +++ b/src/exporters/supabase/pg-client.ts @@ -0,0 +1,93 @@ +import { Pool, type PoolConfig } from 'pg'; +import { SupabasePgError } from './types.js'; + +export interface SupabasePgClientOptions { + connectionString: string; + statementTimeoutMs?: number; + /** + * Inject a custom pool factory. Used by tests to swap in a fake. + * Production code should leave this unset. + */ + poolFactory?: (config: PoolConfig) => PgPoolLike; +} + +/** Minimal subset of `pg.Pool` we use; mirrored for testability. */ +export interface PgPoolLike { + query(sql: string, params?: unknown[]): Promise<{ rows: unknown[] }>; + end(): Promise; +} + +/** + * Subset of {@link SupabasePgClient} that downstream mappers and the password + * merger consume. Tests pass a fake implementing this interface; production + * passes the concrete {@link SupabasePgClient}. + */ +export interface SupabasePgQueryClient { + testConnection(): Promise; + query(sql: string, params?: unknown[]): Promise; + close(): Promise; + poolerWarning?: string; +} + +const DEFAULT_STATEMENT_TIMEOUT_MS = 30_000; + +function resolveStatementTimeout(explicit: number | undefined): number { + if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0) return explicit; + const fromEnv = process.env.SUPABASE_PG_STATEMENT_TIMEOUT_MS; + if (fromEnv) { + const parsed = parseInt(fromEnv, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return DEFAULT_STATEMENT_TIMEOUT_MS; +} + +function detectPoolerWarning(connectionString: string): string | undefined { + // Supabase's transaction pooler (PgBouncer) listens on 6543; direct on 5432. + // PgBouncer in transaction mode breaks prepared statements. + if (/:6543\b/.test(connectionString)) { + return 'Connection string uses port 6543 (PgBouncer pooler). Prepared statements may fail; consider the direct connection on port 5432 for migrations.'; + } + return undefined; +} + +export class SupabasePgClient { + private readonly pool: PgPoolLike; + private readonly statementTimeoutMs: number; + public readonly poolerWarning?: string; + + constructor(options: SupabasePgClientOptions) { + this.statementTimeoutMs = resolveStatementTimeout(options.statementTimeoutMs); + this.poolerWarning = detectPoolerWarning(options.connectionString); + + const factory = + options.poolFactory ?? + ((config: PoolConfig): PgPoolLike => new Pool(config) as unknown as PgPoolLike); + + this.pool = factory({ + connectionString: options.connectionString, + max: 1, + statement_timeout: this.statementTimeoutMs, + }); + } + + async testConnection(): Promise { + try { + await this.pool.query('SELECT 1'); + } catch (error: unknown) { + const message = (error as Error).message ?? 'unknown error'; + throw new SupabasePgError( + `Supabase Postgres connection failed: ${message}`, + 'Confirm the connection string includes ?sslmode=require and uses the direct connection (port 5432, not the pooler on 6543).', + ); + } + } + + async query(sql: string, params?: unknown[]): Promise { + const result = await this.pool.query(sql, params); + return result.rows as T[]; + } + + async close(): Promise { + await this.pool.end(); + } +} diff --git a/src/exporters/supabase/sso-mapper.ts b/src/exporters/supabase/sso-mapper.ts new file mode 100644 index 0000000..2d57191 --- /dev/null +++ b/src/exporters/supabase/sso-mapper.ts @@ -0,0 +1,166 @@ +import { createSamlConnectionRow, type SamlRow } from '../../sso/handoff.js'; +import { parseSamlMetadata } from '../../sso/saml-metadata.js'; +import type { SupabasePgQueryClient } from './pg-client.js'; +import type { SupabaseSamlProviderRow } from './types.js'; + +const SAML_QUERY = ` + SELECT sp.id::text AS id, + sp.sso_provider_id::text AS sso_provider_id, + sp.entity_id AS entity_id, + sp.metadata_xml AS metadata_xml, + sp.metadata_url AS metadata_url, + sp.attribute_mapping AS attribute_mapping, + ssp.resource_id AS resource_id, + COALESCE( + (SELECT array_agg(domain) FROM auth.sso_domains WHERE sso_provider_id = sp.sso_provider_id), + ARRAY[]::text[] + ) AS domains + FROM auth.saml_providers sp + JOIN auth.sso_providers ssp ON ssp.id = sp.sso_provider_id +`; + +const METADATA_FETCH_TIMEOUT_MS = 10_000; + +export interface SamlExportResult { + rows: SamlRow[]; + warnings: string[]; +} + +export async function exportSamlProviders(pg: SupabasePgQueryClient): Promise { + const result: SamlExportResult = { rows: [], warnings: [] }; + + let providers: SupabaseSamlProviderRow[]; + try { + providers = await pg.query(SAML_QUERY); + } catch (error: unknown) { + const message = (error as Error).message ?? 'unknown error'; + if (isMissingTableError(message)) { + result.warnings.push( + 'auth.saml_providers table not found (older GoTrue schema?); SAML export skipped.', + ); + return result; + } + throw error; + } + + for (const provider of providers) { + const row = await buildRow(provider, result.warnings); + result.rows.push(row); + } + + return result; +} + +async function buildRow( + provider: SupabaseSamlProviderRow, + warnings: string[], +): Promise { + let metadataXml = provider.metadata_xml?.trim() ?? ''; + + if (!metadataXml && provider.metadata_url) { + const fetched = await fetchMetadata(provider.metadata_url, warnings, provider.id); + if (fetched) metadataXml = fetched; + } + + const parsed = parseSamlMetadata(metadataXml || undefined); + + if (!parsed.entityId && !provider.entity_id) { + warnings.push(`SAML provider ${provider.id} has no entityId in metadata or row`); + } + if (!parsed.ssoRedirectUrl) { + warnings.push(`SAML provider ${provider.id} has no SingleSignOnService URL`); + } + if (!parsed.x509Cert) { + warnings.push(`SAML provider ${provider.id} has no X.509 certificate in metadata`); + } + + const attrMap = provider.attribute_mapping ?? {}; + const attributes = readAttributeMapping(attrMap); + + if (!attributes.email) { + warnings.push( + `SAML provider ${provider.id} has no email attribute mapping; WorkOS will require manual configuration`, + ); + } + + const domains = (provider.domains ?? []).filter(Boolean).join(','); + if (!domains) { + warnings.push( + `SAML provider ${provider.id} has no domains configured; domain-capture will be empty`, + ); + } + + return createSamlConnectionRow({ + organizationName: '', + organizationId: '', + organizationExternalId: provider.resource_id ?? '', + domains, + idpEntityId: parsed.entityId ?? provider.entity_id ?? '', + idpUrl: parsed.ssoRedirectUrl ?? '', + x509Cert: parsed.x509Cert ?? '', + idpMetadataUrl: provider.metadata_url ?? '', + emailAttribute: attributes.email, + firstNameAttribute: attributes.firstName, + lastNameAttribute: attributes.lastName, + importedId: provider.id, + }); +} + +interface SamlAttributeNames { + email: string; + firstName: string; + lastName: string; +} + +function readAttributeMapping(map: Record): SamlAttributeNames { + const keys = (map.keys ?? map) as Record; + return { + email: pickAttribute(keys, ['email', 'emailaddress', 'email_address']), + firstName: pickAttribute(keys, ['first_name', 'firstname', 'given_name', 'givenname']), + lastName: pickAttribute(keys, ['last_name', 'lastname', 'family_name', 'familyname', 'surname']), + }; +} + +function pickAttribute(source: Record, candidates: string[]): string { + for (const key of Object.keys(source)) { + const lower = key.toLowerCase(); + if (candidates.includes(lower)) { + const value = source[key]; + if (typeof value === 'string') return value; + if (value && typeof value === 'object' && 'name' in value) { + const name = (value as { name?: unknown }).name; + if (typeof name === 'string') return name; + } + } + } + return ''; +} + +async function fetchMetadata( + url: string, + warnings: string[], + providerId: string, +): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS), + headers: { Accept: 'application/xml,text/xml' }, + }); + if (!response.ok) { + warnings.push( + `Failed to fetch SAML metadata for provider ${providerId} from ${url}: HTTP ${response.status}`, + ); + return null; + } + return await response.text(); + } catch (error: unknown) { + warnings.push( + `Failed to fetch SAML metadata for provider ${providerId} from ${url}: ${(error as Error).message}`, + ); + return null; + } +} + +function isMissingTableError(message: string): boolean { + return /does not exist/i.test(message) && /saml_providers|sso_providers|auth\./i.test(message); +} diff --git a/src/exporters/supabase/types.ts b/src/exporters/supabase/types.ts index c2c8ace..6ece74d 100644 --- a/src/exporters/supabase/types.ts +++ b/src/exporters/supabase/types.ts @@ -48,10 +48,41 @@ export interface SupabaseExportStats { totalFetched: number; exported: number; skipped: number; + totpExported: number; + samlExported: number; warnings: string[]; skippedRecords: SupabaseSkippedRecord[]; } +export interface SupabaseMfaFactorRow { + email: string; + factor_type: string; + secret: string; + friendly_name?: string | null; + status: string; +} + +export interface SupabaseSamlProviderRow { + id: string; + sso_provider_id: string; + entity_id: string | null; + metadata_xml: string | null; + metadata_url: string | null; + attribute_mapping: Record | null; + resource_id: string | null; + domains: string[] | null; +} + +export class SupabasePgError extends Error { + hint?: string; + + constructor(message: string, hint?: string) { + super(hint ? `${message}\n hint: ${hint}` : message); + this.name = 'SupabasePgError'; + this.hint = hint; + } +} + export class SupabaseAuthError extends Error { statusCode: number; body: string; diff --git a/src/providers/supabase/index.ts b/src/providers/supabase/index.ts index 7b7b7aa..76233c5 100644 --- a/src/providers/supabase/index.ts +++ b/src/providers/supabase/index.ts @@ -43,13 +43,13 @@ export const supabaseProvider: Provider = { key: 'mfa', name: 'MFA TOTP factors', description: 'Requires Postgres connection', - enabled: false, + enabled: true, }, { key: 'sso', name: 'SAML SSO connections', description: 'Requires Postgres connection', - enabled: false, + enabled: true, }, { key: 'organizations', diff --git a/src/shared/types.ts b/src/shared/types.ts index f93759e..b5b79c1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -139,9 +139,16 @@ export interface SupabaseExportOptions { entities: string[]; rateLimit: number; pageSize: number; + totpIssuer?: string; quiet: boolean; } +export interface SupabasePasswordMergeOptions { + packageDir: string; + dbUrl: string; + quiet?: boolean; +} + export interface ExportSummary { totalUsers: number; totalOrgs: number; diff --git a/tests/providers/supabase/exporter-pg.integration.test.ts b/tests/providers/supabase/exporter-pg.integration.test.ts new file mode 100644 index 0000000..1b87cff --- /dev/null +++ b/tests/providers/supabase/exporter-pg.integration.test.ts @@ -0,0 +1,206 @@ +import { jest } from '@jest/globals'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { exportSupabase } from '../../../src/exporters/supabase/exporter.js'; +import type { SupabasePgQueryClient } from '../../../src/exporters/supabase/pg-client.js'; +import { streamCSV } from '../../../src/shared/csv-utils.js'; + +const FIXTURES_DIR = path.resolve(process.cwd(), 'tests/providers/supabase/fixtures'); + +interface FakePgState { + mfaRows?: Array>; + samlRows?: Array>; + failTestConnection?: boolean; +} + +function fakePgClient(state: FakePgState): SupabasePgQueryClient { + return { + async testConnection() { + if (state.failTestConnection) { + throw new Error('SSL connection required'); + } + }, + async query(sql: string): Promise { + if (/auth\.mfa_factors/i.test(sql)) return (state.mfaRows ?? []) as T[]; + if (/auth\.saml_providers/i.test(sql)) return (state.samlRows ?? []) as T[]; + return [] as T[]; + }, + async close() {}, + }; +} + +const SAMPLE_SAML_METADATA = ` + + + + + MIIDCERT + + + + +`; + +describe('exportSupabase end-to-end with Postgres-backed entities', () => { + let tmpDir: string; + let fetchMock: jest.MockedFunction; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supabase-export-pg-test-')); + fetchMock = jest.fn() as jest.MockedFunction; + global.fetch = fetchMock; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it('produces users + MFA + SAML outputs when --db-url is provided', async () => { + const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); + const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ users: [page1.users[0]] })) // testConnection probe + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(empty)); + + await exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + dbUrl: 'postgresql://user:pw@db.example.com:5432/postgres', + outputDir: tmpDir, + entities: ['users', 'mfa', 'sso'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + pgClientFactory: () => + fakePgClient({ + mfaRows: [ + { + email: 'alice@example.com', + factor_type: 'totp', + secret: 'JBSWY3DPEHPK3PXP', + friendly_name: 'Phone', + status: 'verified', + }, + ], + samlRows: [ + { + id: 'saml-1', + sso_provider_id: 'ssp-1', + entity_id: null, + metadata_xml: SAMPLE_SAML_METADATA, + metadata_url: null, + attribute_mapping: { keys: { email: 'urn:oid:email' } }, + resource_id: 'ext-org-1', + domains: ['example.com'], + }, + ], + }), + }); + + const usersRows = await readCsvRows(path.join(tmpDir, 'users.csv')); + expect(usersRows.length).toBeGreaterThanOrEqual(2); + + const totpRows = await readCsvRows(path.join(tmpDir, 'totp_secrets.csv')); + expect(totpRows).toHaveLength(1); + expect(totpRows[0].email).toBe('alice@example.com'); + expect(totpRows[0].totp_secret).toBe('JBSWY3DPEHPK3PXP'); + expect(totpRows[0].totp_issuer).toBe('Supabase'); + + const samlRows = await readCsvRows(path.join(tmpDir, 'sso/saml_connections.csv')); + expect(samlRows).toHaveLength(1); + expect(samlRows[0].idpUrl).toBe('https://idp.example.com/sso'); + expect(samlRows[0].x509Cert).toBe('MIIDCERT'); + expect(samlRows[0].domains).toBe('example.com'); + + const manifest = JSON.parse(await fsp.readFile(path.join(tmpDir, 'manifest.json'), 'utf-8')); + expect(manifest.entitiesExported.totpSecrets).toBe(1); + expect(manifest.entitiesExported.samlConnections).toBe(1); + }); + + it('completes users.csv with a warning when Postgres connection fails', async () => { + const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); + const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ users: [page1.users[0]] })) + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(empty)); + + await exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + dbUrl: 'postgresql://user:pw@db.example.com:5432/postgres', + outputDir: tmpDir, + entities: ['users', 'mfa', 'sso'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + pgClientFactory: () => fakePgClient({ failTestConnection: true }), + }); + + expect(fs.existsSync(path.join(tmpDir, 'users.csv'))).toBe(true); + const usersRows = await readCsvRows(path.join(tmpDir, 'users.csv')); + expect(usersRows.length).toBeGreaterThan(0); + + const totpRows = await readCsvRows(path.join(tmpDir, 'totp_secrets.csv')); + expect(totpRows).toHaveLength(0); + + const warnings = fs + .readFileSync(path.join(tmpDir, 'warnings.jsonl'), 'utf-8') + .split('\n') + .filter(Boolean); + expect(warnings.some((w) => /Supabase Postgres connection failed/.test(w))).toBe(true); + }); + + it('warns and skips mfa/sso when --db-url is not provided', async () => { + const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); + const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ users: [page1.users[0]] })) + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(empty)); + + await exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + outputDir: tmpDir, + entities: ['users', 'mfa'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + }); + + const totpRows = await readCsvRows(path.join(tmpDir, 'totp_secrets.csv')); + expect(totpRows).toHaveLength(0); + + const warnings = fs + .readFileSync(path.join(tmpDir, 'warnings.jsonl'), 'utf-8') + .split('\n') + .filter(Boolean); + expect(warnings.some((w) => /require --db-url/.test(w))).toBe(true); + }); +}); + +async function readCsvRows(filePath: string): Promise[]> { + const rows: Record[] = []; + for await (const row of streamCSV(filePath)) { + rows.push(row as Record); + } + return rows; +} + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} From 3dd8199a055d233e9cebe7ff3f335d7c577a1136 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 19 May 2026 10:54:30 -0700 Subject: [PATCH 3/6] =?UTF-8?q?feat(supabase):=20Phase=203=20=E2=80=94=20o?= =?UTF-8?q?rg=20and=20role=20extraction=20via=20schema-driven=20Postgres?= =?UTF-8?q?=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights up organizations and per-membership role assignments for Supabase Auth migrations. Customers describe their app's org schema with CLI flags (--org-table, --org-members-table, --membership-role-column, etc.); the exporter builds parameterized Postgres queries with pg-format identifier quoting, runs them through the existing pg client, and emits organizations.csv + organization_memberships.csv into the migration package. - org-schema.ts: identifier validation against /^[a-zA-Z_][a-zA-Z0-9_]*$/ before any SQL is constructed (defense in depth). parseQualifiedIdentifier handles schema-qualified names; validateOrgSchemaFlags enforces the required-group semantics — supplying any of the six required flags requires supplying all of them. - org-query-builder.ts: pg-format-based SQL generation. buildOrgQuery and buildMembershipQuery use %I for every identifier; reserved words like user/select get auto-quoted. buildOrphanMembershipCountQuery does a separate LEFT JOIN count so dropped orphan memberships are surfaced in warnings. - org-mapper.ts: row → CSV conversion. Domains normalize as array→comma, string→passthrough, object→JSON.stringify-plus-warning so unrecognized JSONB shapes are loud, not silent. - role-slug-map.ts: JSON-dict or CSV (role,slug) loader returning Map. applyRoleSlugMap returns either a slug or an "Unmapped role" warning so non-mapped DB roles are visible without aborting the export. - exporter.ts: organizations entity wired into exportPgEntities behind the same --db-url gate as mfa/sso. Critical fix from review cycle: the role-slug-map is loaded ABOVE testConnection so a typo in --role-slug-map fails fast before any network connection rather than silently exporting memberships without role mapping. - CLI: ten new flags on export-supabase (--org-table, --org-id-column, --org-name-column, --org-external-id-column, --org-domains-column, --org-members-table, --membership-user-column, --membership-org-column, --membership-role-column, --role-slug-map). Provider metadata flips organizations entity to enabled — every Phase 3 entity is now available. - 49 new tests (14 org-schema, 9 query-builder with inline snapshots, 12 role-slug-map, 11 org-mapper, 3 integration); full suite 367/367 passing. pg-format ^1 added as a runtime dependency. - docs/supabase-org-schema.md: full schema-flag reference with two worked examples (single-tenant teams; B2B multi-table workaround via VIEW) plus identifier-rule and case-sensitivity callouts. README links the doc and bumps the status note to Phase 3. Review cycle 1: 2 medium findings auto-fixed (role-slug-map load moved to pre-connect; JSONB-shaped domains now emit a warning instead of silently JSON-stringifying). 2 low findings deferred (csv-parse mid-stream destroy is functionally equivalent to header-flag pattern; reserved-word coverage for buildMembershipQuery is implicit via the buildOrgQuery test). --- README.md | 7 +- docs/supabase-org-schema.md | 179 +++++++++++++ package-lock.json | 18 ++ package.json | 2 + src/cli/commands/export-supabase.ts | 66 ++++- .../supabase/__tests__/exporter.test.ts | 2 +- .../supabase/__tests__/org-mapper.test.ts | 195 +++++++++++++++ .../__tests__/org-query-builder.test.ts | 93 +++++++ .../supabase/__tests__/org-schema.test.ts | 119 +++++++++ .../supabase/__tests__/role-slug-map.test.ts | 91 +++++++ src/exporters/supabase/exporter.ts | 58 ++++- src/exporters/supabase/org-mapper.ts | 154 ++++++++++++ src/exporters/supabase/org-query-builder.ts | 72 ++++++ src/exporters/supabase/org-schema.ts | 95 +++++++ src/exporters/supabase/package-writer.ts | 21 +- src/exporters/supabase/role-slug-map.ts | 86 +++++++ src/exporters/supabase/types.ts | 3 + src/providers/supabase/index.ts | 2 +- src/shared/types.ts | 19 ++ .../exporter-orgs.integration.test.ts | 235 ++++++++++++++++++ 20 files changed, 1500 insertions(+), 17 deletions(-) create mode 100644 docs/supabase-org-schema.md create mode 100644 src/exporters/supabase/__tests__/org-mapper.test.ts create mode 100644 src/exporters/supabase/__tests__/org-query-builder.test.ts create mode 100644 src/exporters/supabase/__tests__/org-schema.test.ts create mode 100644 src/exporters/supabase/__tests__/role-slug-map.test.ts create mode 100644 src/exporters/supabase/org-mapper.ts create mode 100644 src/exporters/supabase/org-query-builder.ts create mode 100644 src/exporters/supabase/org-schema.ts create mode 100644 src/exporters/supabase/role-slug-map.ts create mode 100644 tests/providers/supabase/exporter-orgs.integration.test.ts diff --git a/README.md b/README.md index 4fe329f..5edadbd 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ Continue to [Validation](#validation), [Import](#importing-users), and [Post-Imp ## Migrating from Supabase Auth -> **Status — Phase 2**: users + OAuth identities via the Admin API; bcrypt password hashes, TOTP MFA factors, and SAML SSO connections via direct Postgres. Organization/role extraction lands in Phase 3. +> **Status — Phase 3**: users + OAuth identities via the Admin API; bcrypt password hashes, TOTP MFA factors, SAML SSO connections, and organizations/role assignments via direct Postgres. ### 1. Set up Supabase credentials @@ -380,9 +380,10 @@ If you omit `--db-url`, the export still produces `users.csv` (with `metadata.su | -------------------------- | ------------ | ---------------------------------------------------------------------------------------- | | `--rate-limit ` | 50 | Admin API requests per second | | `--page-size ` | 1000 | Users per Admin API page | -| `--entities ` | `users` | Comma-separated entities — `users`, `identities`, `mfa`, `sso` (Phase 2) | -| `--db-url ` | — | Postgres connection string (required for `mfa`/`sso`); also reads `SUPABASE_DB_URL` | +| `--entities ` | `users` | Comma-separated entities — `users`, `identities`, `mfa`, `sso`, `organizations` | +| `--db-url ` | — | Postgres connection string (required for `mfa`/`sso`/`organizations`); also reads `SUPABASE_DB_URL` | | `--totp-issuer ` | `Supabase` | Issuer label written into `totp_secrets.csv` | +| Org schema flags | — | See [docs/supabase-org-schema.md](./docs/supabase-org-schema.md) for the full schema-flag reference (`--org-table`, `--org-members-table`, `--membership-role-column`, `--role-slug-map`, etc.) | ### 3. Merge bcrypt password hashes (optional) diff --git a/docs/supabase-org-schema.md b/docs/supabase-org-schema.md new file mode 100644 index 0000000..1cb0a77 --- /dev/null +++ b/docs/supabase-org-schema.md @@ -0,0 +1,179 @@ +# Supabase organizations & role mapping + +Supabase Auth has no built-in organization concept — orgs typically live in your application's `public.*` schema. To export them, `export-supabase` accepts a group of flags that describe your schema, then builds safe parameterized queries against the tables you name. + +## Required flags (must all be supplied together) + +| Flag | Description | +| ----------------------------- | --------------------------------------------------------------------------- | +| `--org-table` | Postgres table holding organizations (e.g., `public.organizations`) | +| `--org-id-column` | Column on `--org-table` that holds the primary id | +| `--org-name-column` | Column on `--org-table` that holds the display name | +| `--org-members-table` | Postgres table holding org memberships (e.g., `public.org_members`) | +| `--membership-user-column` | Column on `--org-members-table` that holds the user UUID (joined to `auth.users.id`) | +| `--membership-org-column` | Column on `--org-members-table` that holds the org id (joined to `--org-id-column`) | + +If any of the above is supplied without all the others, `export-supabase` fails with `Incomplete org schema flags`. + +## Optional flags + +| Flag | Description | +| ----------------------------- | --------------------------------------------------------------------------- | +| `--org-external-id-column` | Column on `--org-table` that holds the external org identifier (e.g., a `slug`). Defaults to `--org-id-column`. | +| `--org-domains-column` | Column on `--org-table` that holds the org domain. Accepts a `text` scalar or a `text[]` array; arrays are comma-joined. | +| `--membership-role-column` | Column on `--org-members-table` that holds the per-membership role (e.g., `owner`, `member`). Required if you want `role_slugs` populated. | +| `--role-slug-map` | Path to a JSON or CSV file mapping raw DB role values to WorkOS role slugs (see below). | + +## Identifier rules + +Every flag value is validated against `/^[a-zA-Z_][a-zA-Z0-9_]*$/` before any SQL is constructed. This is stricter than what Postgres allows — non-ASCII identifiers and identifiers containing special characters are rejected even though they may exist in your database. This is defense-in-depth against SQL injection through CLI arguments. + +If you have an identifier that doesn't match this pattern, create a database VIEW that renames it (see Example B). + +## Role-slug map format + +The role-slug map translates raw DB role values into the role slugs your WorkOS environment expects. Two formats are accepted: + +**JSON dict (`.json`):** + +```json +{ + "owner": "admin", + "admin": "admin", + "member": "member", + "guest": "viewer" +} +``` + +**CSV with `role,slug` columns (`.csv`):** + +```csv +role,slug +owner,admin +admin,admin +member,member +guest,viewer +``` + +Matching is **case-sensitive**: a DB row with `role = 'Owner'` will not match the key `owner`. Normalize at the source if needed. + +A DB role with no corresponding map entry produces a per-membership warning (`Unmapped role: `) in `warnings.jsonl` and leaves `role_slugs` empty for that membership. Memberships are still exported. + +If `--role-slug-map` is not supplied, the DB role value is written verbatim into `role_slugs`. + +## Example A — Single-tenant teams schema + +Schema: + +```sql +CREATE TABLE public.teams ( + id UUID PRIMARY KEY, + slug TEXT UNIQUE, + name TEXT, + domain TEXT +); + +CREATE TABLE public.team_members ( + team_id UUID REFERENCES public.teams(id), + user_id UUID REFERENCES auth.users(id), + role TEXT, -- 'owner', 'admin', 'member' + PRIMARY KEY (team_id, user_id) +); +``` + +Invocation: + +```bash +workos-migrate export-supabase \ + --url https://abc.supabase.co \ + --service-role-key sk_... \ + --db-url postgresql://postgres:...@db.abc.supabase.co:5432/postgres \ + --package --output-dir ./migration-supabase \ + --entities users,identities,mfa,organizations \ + --org-table public.teams \ + --org-id-column id \ + --org-name-column name \ + --org-external-id-column slug \ + --org-domains-column domain \ + --org-members-table public.team_members \ + --membership-user-column user_id \ + --membership-org-column team_id \ + --membership-role-column role \ + --role-slug-map ./roles.json +``` + +## Example B — B2B with separate orgs and roles tables + +If your schema stores roles in a join table rather than as a column on the membership row, the schema-flag approach won't work directly — only a single column per membership is supported. + +Workaround: create a VIEW that flattens the roles into a single text column. + +Schema: + +```sql +CREATE TABLE public.organizations ( + id UUID PRIMARY KEY, + name TEXT +); + +CREATE TABLE public.organization_members ( + organization_id UUID, + user_id UUID, + PRIMARY KEY (organization_id, user_id) +); + +CREATE TABLE public.organization_member_roles ( + organization_id UUID, + user_id UUID, + role TEXT, + PRIMARY KEY (organization_id, user_id, role) +); +``` + +View: + +```sql +CREATE VIEW public.organization_members_with_roles AS + SELECT om.organization_id, + om.user_id, + ( + SELECT string_agg(omr.role, ',' ORDER BY omr.role) + FROM public.organization_member_roles omr + WHERE omr.organization_id = om.organization_id + AND omr.user_id = om.user_id + ) AS role + FROM public.organization_members om; +``` + +Invocation: + +```bash +workos-migrate export-supabase \ + --url https://abc.supabase.co \ + --service-role-key sk_... \ + --db-url postgresql://postgres:...@db.abc.supabase.co:5432/postgres \ + --package --output-dir ./migration-supabase \ + --entities users,organizations \ + --org-table public.organizations \ + --org-id-column id \ + --org-name-column name \ + --org-members-table public.organization_members_with_roles \ + --membership-user-column user_id \ + --membership-org-column organization_id \ + --membership-role-column role +``` + +The view returns a single `role` column with comma-joined values per membership; the role-slug map maps each raw value through to a WorkOS slug. + +## Orphan memberships + +The exporter joins `--org-members-table` against `auth.users` via INNER JOIN. Memberships referencing a `user_id` that is not present in `auth.users` (orphan memberships, common after manual user deletions) are silently dropped from the output. A separate count query reports them in `warnings.jsonl` so they're visible in the manifest's warnings list. + +## What ends up where + +- `organizations.csv` — one row per org from `--org-table` +- `organization_memberships.csv` — one row per (user, org, role) tuple from `--org-members-table`, joined to `auth.users` for email + external_id +- `warnings.jsonl` — unmapped roles, orphan counts, table-not-found, role-slug-map load failures +- `manifest.json` — `entitiesExported.organizations` and `entitiesExported.memberships` reflect emitted row counts + +Downstream `import-package` consumes these unchanged. diff --git a/package-lock.json b/package-lock.json index a44a30b..e7df573 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "dotenv": "^17.4.2", "fast-xml-parser": "^5.7.2", "pg": "^8.21.0", + "pg-format": "^1.0.4", "prompts": "^2.4.2" }, "bin": { @@ -30,6 +31,7 @@ "@types/jest": "^30.0.0", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", + "@types/pg-format": "^1.0.5", "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.1", @@ -3488,6 +3490,13 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/pg-format": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/pg-format/-/pg-format-1.0.5.tgz", + "integrity": "sha512-i+oEEJEC+1I3XAhgqtVp45Faj8MBbV0Aoq4rHsHD7avgLjyDkaWKObd514g0Q/DOUkdxU0P4CQ0iq2KR4SoJcw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prompts": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", @@ -7270,6 +7279,15 @@ "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", "license": "MIT" }, + "node_modules/pg-format": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz", + "integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", diff --git a/package.json b/package.json index 79393b4..9467a3c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dotenv": "^17.4.2", "fast-xml-parser": "^5.7.2", "pg": "^8.21.0", + "pg-format": "^1.0.4", "prompts": "^2.4.2" }, "devDependencies": { @@ -55,6 +56,7 @@ "@types/jest": "^30.0.0", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", + "@types/pg-format": "^1.0.5", "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^8.59.2", "@typescript-eslint/parser": "^8.59.1", diff --git a/src/cli/commands/export-supabase.ts b/src/cli/commands/export-supabase.ts index 300eb91..88fe66f 100644 --- a/src/cli/commands/export-supabase.ts +++ b/src/cli/commands/export-supabase.ts @@ -2,9 +2,10 @@ import { Command } from 'commander'; import chalk from 'chalk'; import type { SupabaseExportOptions } from '../../shared/types.js'; import { exportSupabase } from '../../exporters/supabase/exporter.js'; +import { validateOrgSchemaFlags } from '../../exporters/supabase/org-schema.js'; -const SUPPORTED_ENTITIES = ['users', 'identities', 'mfa', 'sso']; -const PG_ENTITIES = new Set(['mfa', 'sso']); +const SUPPORTED_ENTITIES = ['users', 'identities', 'mfa', 'sso', 'organizations']; +const PG_ENTITIES = new Set(['mfa', 'sso', 'organizations']); function parseEntities(value: string | undefined): string[] { if (!value) return ['users']; @@ -24,18 +25,49 @@ export function registerExportSupabaseCommand(program: Command): void { .option('--output-dir ', 'Output directory for the migration package') .option( '--entities ', - 'Comma-separated entities to export (users, identities, mfa, sso)', + 'Comma-separated entities to export (users, identities, mfa, sso, organizations)', 'users', ) .option('--rate-limit ', 'Admin API requests per second', '50') .option('--page-size ', 'Admin API page size', '1000') .option( '--db-url ', - 'Postgres connection string (required for mfa and sso entities; can also be supplied via SUPABASE_DB_URL)', + 'Postgres connection string (required for mfa, sso, organizations; or set SUPABASE_DB_URL)', ) + .option('--totp-issuer ', 'Issuer label written into totp_secrets.csv (default: Supabase)') .option( - '--totp-issuer ', - 'Issuer label written into totp_secrets.csv (default: Supabase)', + '--org-table ', + 'Postgres table holding organizations (e.g., public.organizations)', + ) + .option('--org-id-column ', 'Column on --org-table that holds the org primary id') + .option('--org-name-column ', 'Column on --org-table that holds the org display name') + .option( + '--org-external-id-column ', + 'Column on --org-table that holds the external org identifier (defaults to org-id-column)', + ) + .option( + '--org-domains-column ', + 'Column on --org-table that holds the org domain(s); string or text[] both accepted', + ) + .option( + '--org-members-table
', + 'Postgres table holding org memberships (e.g., public.org_members)', + ) + .option( + '--membership-user-column ', + 'Column on --org-members-table that holds the user UUID (joined to auth.users.id)', + ) + .option( + '--membership-org-column ', + 'Column on --org-members-table that holds the org id (joined to --org-id-column)', + ) + .option( + '--membership-role-column ', + 'Column on --org-members-table that holds the per-membership role (optional)', + ) + .option( + '--role-slug-map ', + 'JSON or CSV file mapping raw DB role values to WorkOS role slugs', ) .option('--quiet', 'Suppress progress output') .action(async (opts) => { @@ -65,6 +97,27 @@ export function registerExportSupabaseCommand(program: Command): void { ); } + const orgSchema = validateOrgSchemaFlags({ + orgTable: opts.orgTable, + orgIdColumn: opts.orgIdColumn, + orgNameColumn: opts.orgNameColumn, + orgExternalIdColumn: opts.orgExternalIdColumn, + orgDomainsColumn: opts.orgDomainsColumn, + membersTable: opts.orgMembersTable, + membershipUserColumn: opts.membershipUserColumn, + membershipOrgColumn: opts.membershipOrgColumn, + membershipRoleColumn: opts.membershipRoleColumn, + roleSlugMapPath: opts.roleSlugMap, + }); + + if (entities.includes('organizations') && !orgSchema) { + console.error( + chalk.yellow( + 'Warning: --entities includes "organizations" but no org schema flags supplied. Organizations export will be skipped.', + ), + ); + } + const options: SupabaseExportOptions = { url: opts.url, serviceRoleKey: opts.serviceRoleKey, @@ -74,6 +127,7 @@ export function registerExportSupabaseCommand(program: Command): void { rateLimit: parseInt(opts.rateLimit, 10), pageSize: parseInt(opts.pageSize, 10), totpIssuer: opts.totpIssuer, + orgSchema: orgSchema ?? undefined, quiet: opts.quiet ?? false, }; diff --git a/src/exporters/supabase/__tests__/exporter.test.ts b/src/exporters/supabase/__tests__/exporter.test.ts index d3d6ffe..315cb19 100644 --- a/src/exporters/supabase/__tests__/exporter.test.ts +++ b/src/exporters/supabase/__tests__/exporter.test.ts @@ -109,7 +109,7 @@ describe('exportSupabase (end-to-end)', () => { url: 'https://demo.supabase.co', serviceRoleKey: 'sb-service-role-jwt', outputDir: tmpDir, - entities: ['users', 'organizations'], + entities: ['users', 'role_definitions'], rateLimit: 1000, pageSize: 100, quiet: true, diff --git a/src/exporters/supabase/__tests__/org-mapper.test.ts b/src/exporters/supabase/__tests__/org-mapper.test.ts new file mode 100644 index 0000000..9977226 --- /dev/null +++ b/src/exporters/supabase/__tests__/org-mapper.test.ts @@ -0,0 +1,195 @@ +import type { PoolConfig } from 'pg'; +import type { OrgSchemaConfig } from '../../../shared/types.js'; +import { + exportOrganizations, + mapMembershipRow, + mapOrgRow, + type MembershipQueryRow, + type OrgQueryRow, +} from '../org-mapper.js'; +import { SupabasePgClient, type PgPoolLike } from '../pg-client.js'; + +const CONFIG: OrgSchemaConfig = { + orgTable: { schema: 'public', name: 'workspaces' }, + orgIdColumn: 'id', + orgNameColumn: 'name', + orgExternalIdColumn: 'slug', + orgDomainsColumn: 'domain', + membersTable: { schema: 'public', name: 'workspace_members' }, + membershipUserColumn: 'user_id', + membershipOrgColumn: 'workspace_id', + membershipRoleColumn: 'role', +}; + +function fakeClient(handler: (sql: string) => unknown[]): SupabasePgClient { + const factory = (_config: PoolConfig): PgPoolLike => ({ + async query(sql: string) { + return { rows: handler(sql) }; + }, + async end() {}, + }); + return new SupabasePgClient({ connectionString: 'postgresql://x', poolFactory: factory }); +} + +describe('mapOrgRow', () => { + it('passes org_external_id through and leaves org_id blank', () => { + const row: OrgQueryRow = { + org_internal_id: 'uuid-1', + org_name: 'Acme', + org_external_id: 'acme', + org_domains: 'acme.com', + }; + const { csvRow, warning } = mapOrgRow(row); + expect(csvRow).toEqual({ + org_id: '', + org_external_id: 'acme', + org_name: 'Acme', + domains: 'acme.com', + metadata: '', + }); + expect(warning).toBeUndefined(); + }); + + it('joins an array of domains with commas', () => { + expect( + mapOrgRow({ + org_internal_id: 'u', + org_name: 'X', + org_external_id: 'x', + org_domains: ['a.com', 'b.com'], + }).csvRow.domains, + ).toBe('a.com,b.com'); + }); + + it('emits empty domains when null', () => { + expect( + mapOrgRow({ + org_internal_id: 'u', + org_name: 'X', + org_external_id: 'x', + org_domains: null, + }).csvRow.domains, + ).toBe(''); + }); + + it('falls back to org_internal_id when org_external_id is missing', () => { + expect( + mapOrgRow({ + org_internal_id: 'fallback', + org_name: 'X', + org_external_id: '', + org_domains: null, + }).csvRow.org_external_id, + ).toBe('fallback'); + }); + + it('JSON-stringifies object-shaped domains and emits a warning', () => { + const result = mapOrgRow({ + org_internal_id: 'u', + org_name: 'X', + org_external_id: 'org-1', + org_domains: { primary: 'acme.com' } as unknown as string, + }); + expect(result.csvRow.domains).toBe('{"primary":"acme.com"}'); + expect(result.warning).toMatch(/Unrecognized domains shape for org org-1/); + }); +}); + +describe('mapMembershipRow', () => { + const ROW: MembershipQueryRow = { + email: 'alice@example.com', + user_external_id: 'uuid-1', + org_external_id: 'acme', + role: 'owner', + }; + + it('maps role through the slug map when supplied', () => { + const map = new Map([['owner', 'admin']]); + const { csvRow, warning } = mapMembershipRow(ROW, map); + expect(csvRow.role_slugs).toBe('admin'); + expect(warning).toBeUndefined(); + }); + + it('passes the raw role through when no slug map is provided', () => { + const { csvRow, warning } = mapMembershipRow(ROW, undefined); + expect(csvRow.role_slugs).toBe('owner'); + expect(warning).toBeUndefined(); + }); + + it('returns a warning when the role is not in the slug map', () => { + const map = new Map([['admin', 'admin']]); + const { csvRow, warning } = mapMembershipRow(ROW, map); + expect(csvRow.role_slugs).toBe(''); + expect(warning).toBe('Unmapped role: owner'); + }); + + it('leaves role_slugs empty for null role', () => { + const { csvRow } = mapMembershipRow({ ...ROW, role: null }, new Map()); + expect(csvRow.role_slugs).toBe(''); + }); +}); + +describe('exportOrganizations', () => { + it('runs the three queries and aggregates rows', async () => { + const pg = fakeClient((sql) => { + if (/AS org_internal_id/.test(sql)) { + return [ + { org_internal_id: 'u1', org_name: 'Acme', org_external_id: 'acme', org_domains: 'acme.com' }, + ]; + } + if (/u\.email AS email/.test(sql)) { + return [ + { + email: 'alice@example.com', + user_external_id: 'uuid-1', + org_external_id: 'acme', + role: 'owner', + }, + ]; + } + if (/LEFT JOIN auth\.users/.test(sql)) { + return [{ count: 2 }]; + } + return []; + }); + + const map = new Map([['owner', 'admin']]); + const result = await exportOrganizations(pg, CONFIG, map); + expect(result.organizationRows).toHaveLength(1); + expect(result.organizationRows[0].org_external_id).toBe('acme'); + expect(result.membershipRows).toHaveLength(1); + expect(result.membershipRows[0].role_slugs).toBe('admin'); + expect(result.orphanCount).toBe(2); + expect(result.warnings.some((w) => /2 membership row\(s\)/.test(w))).toBe(true); + }); + + it('warns and returns empty when the org table does not exist', async () => { + const pg = fakeClient((sql) => { + if (/AS org_internal_id/.test(sql)) { + throw new Error('relation "public.workspaces" does not exist'); + } + return []; + }); + const result = await exportOrganizations(pg, CONFIG, undefined); + expect(result.organizationRows).toHaveLength(0); + expect(result.warnings[0]).toMatch(/Org table not found/); + }); + + it('warns and returns empty memberships when the members table does not exist', async () => { + const pg = fakeClient((sql) => { + if (/AS org_internal_id/.test(sql)) { + return [ + { org_internal_id: 'u1', org_name: 'Acme', org_external_id: 'acme', org_domains: null }, + ]; + } + if (/u\.email AS email/.test(sql)) { + throw new Error('relation "public.workspace_members" does not exist'); + } + return []; + }); + const result = await exportOrganizations(pg, CONFIG, undefined); + expect(result.organizationRows).toHaveLength(1); + expect(result.membershipRows).toHaveLength(0); + expect(result.warnings.some((w) => /Members table not found/.test(w))).toBe(true); + }); +}); diff --git a/src/exporters/supabase/__tests__/org-query-builder.test.ts b/src/exporters/supabase/__tests__/org-query-builder.test.ts new file mode 100644 index 0000000..3b1c7f6 --- /dev/null +++ b/src/exporters/supabase/__tests__/org-query-builder.test.ts @@ -0,0 +1,93 @@ +import type { OrgSchemaConfig } from '../../../shared/types.js'; +import { + buildMembershipQuery, + buildOrgQuery, + buildOrphanMembershipCountQuery, +} from '../org-query-builder.js'; + +const MINIMAL_CONFIG: OrgSchemaConfig = { + orgTable: { schema: 'public', name: 'organizations' }, + orgIdColumn: 'id', + orgNameColumn: 'name', + membersTable: { schema: 'public', name: 'org_members' }, + membershipUserColumn: 'user_id', + membershipOrgColumn: 'organization_id', +}; + +const FULL_CONFIG: OrgSchemaConfig = { + orgTable: { schema: 'public', name: 'workspaces' }, + orgIdColumn: 'id', + orgNameColumn: 'name', + orgExternalIdColumn: 'slug', + orgDomainsColumn: 'domain', + membersTable: { schema: 'public', name: 'workspace_members' }, + membershipUserColumn: 'user_id', + membershipOrgColumn: 'workspace_id', + membershipRoleColumn: 'role', +}; + +describe('buildOrgQuery', () => { + it('builds a minimal SELECT against schema.table', () => { + expect(buildOrgQuery(MINIMAL_CONFIG)).toMatchInlineSnapshot( + `"SELECT id::text AS org_internal_id, name AS org_name, id::text AS org_external_id, NULL::text AS org_domains FROM public.organizations"`, + ); + }); + + it('includes optional columns when present', () => { + expect(buildOrgQuery(FULL_CONFIG)).toMatchInlineSnapshot( + `"SELECT id::text AS org_internal_id, name AS org_name, slug::text AS org_external_id, domain AS org_domains FROM public.workspaces"`, + ); + }); + + it('quotes reserved-word identifiers', () => { + const config: OrgSchemaConfig = { + ...MINIMAL_CONFIG, + orgTable: { schema: 'public', name: 'select' }, + orgNameColumn: 'user', + }; + const sql = buildOrgQuery(config); + expect(sql).toContain('"user" AS org_name'); + expect(sql).toContain('public."select"'); + }); + + it('defaults to public schema when none is provided', () => { + const config: OrgSchemaConfig = { + ...MINIMAL_CONFIG, + orgTable: { name: 'organizations' }, + }; + expect(buildOrgQuery(config)).toContain('FROM public.organizations'); + }); +}); + +describe('buildMembershipQuery', () => { + it('builds an INNER JOIN against auth.users for email resolution', () => { + const sql = buildMembershipQuery(MINIMAL_CONFIG); + expect(sql).toContain('JOIN auth.users u ON u.id = m.user_id'); + expect(sql).toContain('FROM public.org_members m'); + expect(sql).toContain('JOIN public.organizations o ON o.id = m.organization_id'); + expect(sql).toContain('NULL::text AS role'); + }); + + it('selects the role column when --membership-role-column is provided', () => { + const sql = buildMembershipQuery(FULL_CONFIG); + expect(sql).toContain('m.role::text AS role'); + }); + + it('uses the org_external_id column on the orgs table for the join projection', () => { + const sql = buildMembershipQuery(FULL_CONFIG); + expect(sql).toContain('o.slug::text AS org_external_id'); + }); + + it('falls back to membership-org-column when no orgExternalIdColumn is set', () => { + const sql = buildMembershipQuery(MINIMAL_CONFIG); + expect(sql).toContain('m.organization_id::text AS org_external_id'); + }); +}); + +describe('buildOrphanMembershipCountQuery', () => { + it('builds a LEFT JOIN counting memberships whose user is missing from auth.users', () => { + expect(buildOrphanMembershipCountQuery(MINIMAL_CONFIG)).toMatchInlineSnapshot( + `"SELECT count(*)::int AS count FROM public.org_members m LEFT JOIN auth.users u ON u.id = m.user_id WHERE u.id IS NULL"`, + ); + }); +}); diff --git a/src/exporters/supabase/__tests__/org-schema.test.ts b/src/exporters/supabase/__tests__/org-schema.test.ts new file mode 100644 index 0000000..e0743b6 --- /dev/null +++ b/src/exporters/supabase/__tests__/org-schema.test.ts @@ -0,0 +1,119 @@ +import { + parseQualifiedIdentifier, + validateOrgSchemaFlags, + type OrgSchemaFlags, +} from '../org-schema.js'; + +describe('parseQualifiedIdentifier', () => { + it('parses an unqualified identifier', () => { + expect(parseQualifiedIdentifier('organizations')).toEqual({ name: 'organizations' }); + }); + + it('parses a schema-qualified identifier', () => { + expect(parseQualifiedIdentifier('public.orgs')).toEqual({ schema: 'public', name: 'orgs' }); + }); + + it('accepts reserved words (pg-format will quote them later)', () => { + expect(parseQualifiedIdentifier('user')).toEqual({ name: 'user' }); + expect(parseQualifiedIdentifier('public.select')).toEqual({ schema: 'public', name: 'select' }); + }); + + it('rejects identifiers with semicolons', () => { + expect(() => parseQualifiedIdentifier('orgs; DROP TABLE x')).toThrow(/Invalid identifier/); + }); + + it('rejects identifiers with quotes', () => { + expect(() => parseQualifiedIdentifier('"orgs"')).toThrow(/Invalid identifier/); + }); + + it('rejects identifiers with spaces', () => { + expect(() => parseQualifiedIdentifier('public orgs')).toThrow(/Invalid identifier/); + }); + + it('rejects identifiers with three or more dot-separated parts', () => { + expect(() => parseQualifiedIdentifier('a.b.c')).toThrow(/Invalid identifier/); + }); + + it('rejects identifiers starting with a digit', () => { + expect(() => parseQualifiedIdentifier('1orgs')).toThrow(/Invalid identifier/); + }); +}); + +describe('validateOrgSchemaFlags', () => { + it('returns null when no flags are supplied', () => { + expect(validateOrgSchemaFlags({})).toBeNull(); + }); + + it('parses a complete minimal config', () => { + const flags: OrgSchemaFlags = { + orgTable: 'public.organizations', + orgIdColumn: 'id', + orgNameColumn: 'name', + membersTable: 'public.org_members', + membershipUserColumn: 'user_id', + membershipOrgColumn: 'organization_id', + }; + expect(validateOrgSchemaFlags(flags)).toEqual({ + orgTable: { schema: 'public', name: 'organizations' }, + orgIdColumn: 'id', + orgNameColumn: 'name', + membersTable: { schema: 'public', name: 'org_members' }, + membershipUserColumn: 'user_id', + membershipOrgColumn: 'organization_id', + }); + }); + + it('includes optional columns when supplied', () => { + const config = validateOrgSchemaFlags({ + orgTable: 'public.workspaces', + orgIdColumn: 'id', + orgNameColumn: 'name', + orgExternalIdColumn: 'slug', + orgDomainsColumn: 'domain', + membersTable: 'public.workspace_members', + membershipUserColumn: 'user_id', + membershipOrgColumn: 'workspace_id', + membershipRoleColumn: 'role', + roleSlugMapPath: './roles.json', + }); + expect(config?.orgExternalIdColumn).toBe('slug'); + expect(config?.orgDomainsColumn).toBe('domain'); + expect(config?.membershipRoleColumn).toBe('role'); + expect(config?.roleSlugMapPath).toBe('./roles.json'); + }); + + it('throws when only some required flags are present', () => { + expect(() => + validateOrgSchemaFlags({ + orgTable: 'public.orgs', + orgIdColumn: 'id', + }), + ).toThrow(/Incomplete org schema flags/); + }); + + it('throws with an injection attempt in the table flag', () => { + expect(() => + validateOrgSchemaFlags({ + orgTable: 'public.organizations; DROP TABLE x', + orgIdColumn: 'id', + orgNameColumn: 'name', + membersTable: 'public.members', + membershipUserColumn: 'user_id', + membershipOrgColumn: 'org_id', + }), + ).toThrow(/Invalid identifier/); + }); + + it('throws when a column flag contains invalid characters', () => { + expect(() => + validateOrgSchemaFlags({ + orgTable: 'public.orgs', + orgIdColumn: 'id; --', + orgNameColumn: 'name', + membersTable: 'public.members', + membershipUserColumn: 'user_id', + membershipOrgColumn: 'org_id', + }), + ).toThrow(/Invalid identifier for --org-id-column/); + }); +}); diff --git a/src/exporters/supabase/__tests__/role-slug-map.test.ts b/src/exporters/supabase/__tests__/role-slug-map.test.ts new file mode 100644 index 0000000..8bd76ea --- /dev/null +++ b/src/exporters/supabase/__tests__/role-slug-map.test.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { applyRoleSlugMap, loadRoleSlugMap } from '../role-slug-map.js'; + +describe('loadRoleSlugMap', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'role-slug-map-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('loads a JSON dict and returns a Map', async () => { + const jsonPath = path.join(tmpDir, 'roles.json'); + await fsp.writeFile(jsonPath, JSON.stringify({ owner: 'admin', member: 'member' })); + + const map = await loadRoleSlugMap(jsonPath); + expect(map.get('owner')).toBe('admin'); + expect(map.get('member')).toBe('member'); + expect(map.size).toBe(2); + }); + + it('loads a CSV with role,slug columns and returns a Map', async () => { + const csvPath = path.join(tmpDir, 'roles.csv'); + await fsp.writeFile(csvPath, 'role,slug\nowner,admin\nmember,member\n'); + + const map = await loadRoleSlugMap(csvPath); + expect(map.get('owner')).toBe('admin'); + expect(map.get('member')).toBe('member'); + }); + + it('rejects unsupported extensions', async () => { + await expect(loadRoleSlugMap(path.join(tmpDir, 'roles.yaml'))).rejects.toThrow(/Unsupported.*\.yaml/); + }); + + it('throws when the file does not exist (JSON branch)', async () => { + await expect(loadRoleSlugMap(path.join(tmpDir, 'missing.json'))).rejects.toThrow(); + }); + + it('throws when JSON content is not an object dict', async () => { + const jsonPath = path.join(tmpDir, 'array.json'); + await fsp.writeFile(jsonPath, JSON.stringify(['a', 'b'])); + await expect(loadRoleSlugMap(jsonPath)).rejects.toThrow(/must be an object dict/); + }); + + it('throws when a JSON value is non-string', async () => { + const jsonPath = path.join(tmpDir, 'bad.json'); + await fsp.writeFile(jsonPath, JSON.stringify({ owner: 42 })); + await expect(loadRoleSlugMap(jsonPath)).rejects.toThrow(/must be a string/); + }); + + it('throws when the CSV lacks required columns', async () => { + const csvPath = path.join(tmpDir, 'wrong.csv'); + await fsp.writeFile(csvPath, 'foo,bar\n1,2\n'); + await expect(loadRoleSlugMap(csvPath)).rejects.toThrow(/columns "role" and "slug"/); + }); +}); + +describe('applyRoleSlugMap', () => { + const map = new Map([ + ['owner', 'admin'], + ['member', 'member'], + ]); + + it('returns the mapped slug on a hit', () => { + expect(applyRoleSlugMap(map, 'owner')).toEqual({ slug: 'admin' }); + }); + + it('returns a warning on a miss', () => { + expect(applyRoleSlugMap(map, 'guest')).toEqual({ warning: 'Unmapped role: guest' }); + }); + + it('passes through the raw value when no map is provided', () => { + expect(applyRoleSlugMap(undefined, 'owner')).toEqual({ slug: 'owner' }); + }); + + it('returns empty result for null/empty raw values', () => { + expect(applyRoleSlugMap(map, null)).toEqual({}); + expect(applyRoleSlugMap(map, '')).toEqual({}); + expect(applyRoleSlugMap(map, ' ')).toEqual({}); + }); + + it('is case-sensitive', () => { + expect(applyRoleSlugMap(map, 'Owner')).toEqual({ warning: 'Unmapped role: Owner' }); + }); +}); diff --git a/src/exporters/supabase/exporter.ts b/src/exporters/supabase/exporter.ts index 0a81be8..451d6f5 100644 --- a/src/exporters/supabase/exporter.ts +++ b/src/exporters/supabase/exporter.ts @@ -5,11 +5,13 @@ import { SupabasePgClient } from './pg-client.js'; import { mapSupabaseUser } from './user-mapper.js'; import { exportMfaFactors } from './mfa-mapper.js'; import { exportSamlProviders } from './sso-mapper.js'; +import { exportOrganizations } from './org-mapper.js'; +import { loadRoleSlugMap, type RoleSlugMap } from './role-slug-map.js'; import { openSupabasePackage, type SupabaseWriterContext } from './package-writer.js'; import type { SupabasePgQueryClient } from './pg-client.js'; -const SUPPORTED_ENTITIES = new Set(['users', 'identities', 'mfa', 'sso']); -const PG_ENTITIES = new Set(['mfa', 'sso']); +const SUPPORTED_ENTITIES = new Set(['users', 'identities', 'mfa', 'sso', 'organizations']); +const PG_ENTITIES = new Set(['mfa', 'sso', 'organizations']); export interface ExportSupabaseInternal extends SupabaseExportOptions { /** Test seam — replace the Postgres client used for mfa/sso exports. */ @@ -25,10 +27,13 @@ export async function exportSupabase(options: ExportSupabaseInternal): Promise !SUPPORTED_ENTITIES.has(entity)); if (unsupported.length > 0) { throw new Error( - `Unsupported entities for Supabase export: ${unsupported.join(', ')}. Supported in Phase 2: users, identities, mfa, sso.`, + `Unsupported entities for Supabase export: ${unsupported.join(', ')}. Supported: users, identities, mfa, sso, organizations.`, ); } + // Fail fast on a bad role-slug-map path — before opening any network connection. + const roleSlugMap = await maybeLoadRoleSlugMap(options); + const client = new SupabaseAdminClient({ url: options.url, serviceRoleKey: options.serviceRoleKey, @@ -49,7 +54,7 @@ export async function exportSupabase(options: ExportSupabaseInternal): Promise 0) logger.info(` TOTP factors: ${pkg.stats.totpExported}`); if (pkg.stats.samlExported > 0) logger.info(` SAML providers: ${pkg.stats.samlExported}`); + if (pkg.stats.orgsExported > 0) logger.info(` Organizations: ${pkg.stats.orgsExported}`); + if (pkg.stats.membershipsExported > 0) + logger.info(` Memberships: ${pkg.stats.membershipsExported}`); logger.info(` Warnings: ${pkg.stats.warnings.length}`); logger.info(` Duration: ${duration}ms`); logger.info(` Output: ${pkg.rootDir}`); @@ -114,10 +122,25 @@ async function exportUsers( } } +async function maybeLoadRoleSlugMap( + options: ExportSupabaseInternal, +): Promise { + const path = options.orgSchema?.roleSlugMapPath; + if (!path) return undefined; + try { + return await loadRoleSlugMap(path); + } catch (error: unknown) { + throw new Error(`Failed to load --role-slug-map ${path}: ${(error as Error).message}`, { + cause: error, + }); + } +} + async function exportPgEntities( pkg: SupabaseWriterContext, options: ExportSupabaseInternal, requested: string[], + roleSlugMap: RoleSlugMap | undefined, ): Promise { const pgRequested = requested.filter((entity) => PG_ENTITIES.has(entity)); if (pgRequested.length === 0) return; @@ -161,7 +184,34 @@ async function exportPgEntities( if (saml.warnings.length > 0) pkg.stats.warnings.push(...saml.warnings); await pkg.writeSamlConnections(saml.rows); } + + if (requested.includes('organizations')) { + await runOrganizationsExport(pg, pkg, options, roleSlugMap); + } } finally { await pg.close(); } } + +async function runOrganizationsExport( + pg: SupabasePgQueryClient, + pkg: SupabaseWriterContext, + options: ExportSupabaseInternal, + roleSlugMap: RoleSlugMap | undefined, +): Promise { + if (!options.orgSchema) { + pkg.stats.warnings.push( + 'Requested entity "organizations" but no org schema flags supplied; pass --org-table, --org-members-table, etc. Organizations export skipped.', + ); + return; + } + + if (!options.quiet) logger.info(' Exporting organizations...'); + + const result = await exportOrganizations(pg, options.orgSchema, roleSlugMap); + if (result.warnings.length > 0) pkg.stats.warnings.push(...result.warnings); + pkg.stats.orphanMemberships += result.orphanCount; + + await pkg.writeOrganizations(result.organizationRows); + await pkg.writeMemberships(result.membershipRows); +} diff --git a/src/exporters/supabase/org-mapper.ts b/src/exporters/supabase/org-mapper.ts new file mode 100644 index 0000000..9315b78 --- /dev/null +++ b/src/exporters/supabase/org-mapper.ts @@ -0,0 +1,154 @@ +import { + ORGANIZATION_CSV_HEADERS, + ORGANIZATION_MEMBERSHIP_CSV_HEADERS, +} from '../../package/manifest.js'; +import type { OrgSchemaConfig } from '../../shared/types.js'; +import type { SupabasePgQueryClient } from './pg-client.js'; +import { + buildMembershipQuery, + buildOrgQuery, + buildOrphanMembershipCountQuery, +} from './org-query-builder.js'; +import { applyRoleSlugMap, type RoleSlugMap } from './role-slug-map.js'; + +export interface OrgQueryRow { + org_internal_id: string; + org_name: string; + org_external_id: string; + org_domains: string | string[] | null; +} + +export interface MembershipQueryRow { + email: string; + user_external_id: string; + org_external_id: string; + role: string | null; +} + +type OrgCsvRow = Record<(typeof ORGANIZATION_CSV_HEADERS)[number], string>; +type MembershipCsvRow = Record<(typeof ORGANIZATION_MEMBERSHIP_CSV_HEADERS)[number], string>; + +export interface OrgExportResult { + organizationRows: OrgCsvRow[]; + membershipRows: MembershipCsvRow[]; + warnings: string[]; + orphanCount: number; +} + +export function mapOrgRow(row: OrgQueryRow): { csvRow: OrgCsvRow; warning?: string } { + const externalId = row.org_external_id?.toString().trim() || row.org_internal_id?.toString().trim() || ''; + const { domains, warning } = normalizeDomains(row.org_domains, externalId); + const csvRow: OrgCsvRow = { + org_id: '', + org_external_id: externalId, + org_name: row.org_name ?? '', + domains, + metadata: '', + }; + return warning ? { csvRow, warning } : { csvRow }; +} + +export function mapMembershipRow( + row: MembershipQueryRow, + roleSlugMap: RoleSlugMap | undefined, +): { csvRow: MembershipCsvRow; warning?: string } { + const { slug, warning } = applyRoleSlugMap(roleSlugMap, row.role); + const csvRow: MembershipCsvRow = { + email: row.email ?? '', + external_id: row.user_external_id ?? '', + user_id: '', + org_id: '', + org_external_id: row.org_external_id ?? '', + org_name: '', + role_slugs: slug ?? '', + metadata: '', + }; + return warning ? { csvRow, warning } : { csvRow }; +} + +export async function exportOrganizations( + pg: SupabasePgQueryClient, + config: OrgSchemaConfig, + roleSlugMap: RoleSlugMap | undefined, +): Promise { + const warnings: string[] = []; + + let orgRows: OrgQueryRow[]; + try { + orgRows = await pg.query(buildOrgQuery(config)); + } catch (error: unknown) { + const message = (error as Error).message ?? 'unknown error'; + if (isMissingRelationError(message)) { + warnings.push( + `Org table not found (${formatTable(config.orgTable.schema, config.orgTable.name)}): ${message}. Organizations export skipped.`, + ); + return { organizationRows: [], membershipRows: [], warnings, orphanCount: 0 }; + } + throw error; + } + + const organizationRows: OrgCsvRow[] = []; + for (const row of orgRows) { + const { csvRow, warning } = mapOrgRow(row); + organizationRows.push(csvRow); + if (warning) warnings.push(warning); + } + + let membershipRowsRaw: MembershipQueryRow[]; + try { + membershipRowsRaw = await pg.query(buildMembershipQuery(config)); + } catch (error: unknown) { + const message = (error as Error).message ?? 'unknown error'; + if (isMissingRelationError(message)) { + warnings.push( + `Members table not found (${formatTable(config.membersTable.schema, config.membersTable.name)}): ${message}. Memberships export skipped.`, + ); + return { organizationRows, membershipRows: [], warnings, orphanCount: 0 }; + } + throw error; + } + + const membershipRows: MembershipCsvRow[] = []; + for (const row of membershipRowsRaw) { + const { csvRow, warning } = mapMembershipRow(row, roleSlugMap); + membershipRows.push(csvRow); + if (warning) warnings.push(warning); + } + + let orphanCount = 0; + try { + const orphanRows = await pg.query<{ count: number }>(buildOrphanMembershipCountQuery(config)); + orphanCount = Number(orphanRows[0]?.count ?? 0); + if (orphanCount > 0) { + warnings.push( + `${orphanCount} membership row(s) in ${formatTable(config.membersTable.schema, config.membersTable.name)} reference a user_id not present in auth.users; dropped from output.`, + ); + } + } catch { + // Orphan count is best-effort; failures don't block the export. + } + + return { organizationRows, membershipRows, warnings, orphanCount }; +} + +function normalizeDomains( + value: string | string[] | null | undefined, + orgIdentifier: string, +): { domains: string; warning?: string } { + if (value === null || value === undefined) return { domains: '' }; + if (Array.isArray(value)) return { domains: value.filter(Boolean).join(',') }; + if (typeof value === 'string') return { domains: value.trim() }; + // JSONB or other object shape — surface a warning so it's not silently degraded. + return { + domains: JSON.stringify(value), + warning: `Unrecognized domains shape for org ${orgIdentifier || ''}; serialized as JSON.`, + }; +} + +function isMissingRelationError(message: string): boolean { + return /does not exist/i.test(message) && /relation|table/i.test(message); +} + +function formatTable(schema: string | undefined, name: string): string { + return schema ? `${schema}.${name}` : name; +} diff --git a/src/exporters/supabase/org-query-builder.ts b/src/exporters/supabase/org-query-builder.ts new file mode 100644 index 0000000..7926a21 --- /dev/null +++ b/src/exporters/supabase/org-query-builder.ts @@ -0,0 +1,72 @@ +import pgFormatModule from 'pg-format'; +import type { OrgSchemaConfig, QualifiedIdentifier } from '../../shared/types.js'; + +// `pg-format` is a CJS module; the default export carries the format function. +const format: (fmt: string, ...args: unknown[]) => string = + (pgFormatModule as unknown as { default?: typeof pgFormatModule }).default ?? pgFormatModule; + +const DEFAULT_SCHEMA = 'public'; + +function qualified(table: QualifiedIdentifier): { schema: string; name: string } { + return { schema: table.schema ?? DEFAULT_SCHEMA, name: table.name }; +} + +export function buildOrgQuery(config: OrgSchemaConfig): string { + const t = qualified(config.orgTable); + + const selectParts: string[] = [ + format('%I::text AS org_internal_id', config.orgIdColumn), + format('%I AS org_name', config.orgNameColumn), + ]; + + if (config.orgExternalIdColumn) { + selectParts.push(format('%I::text AS org_external_id', config.orgExternalIdColumn)); + } else { + selectParts.push(format('%I::text AS org_external_id', config.orgIdColumn)); + } + + if (config.orgDomainsColumn) { + selectParts.push(format('%I AS org_domains', config.orgDomainsColumn)); + } else { + selectParts.push(`NULL::text AS org_domains`); + } + + return format('SELECT %s FROM %I.%I', selectParts.join(', '), t.schema, t.name); +} + +export function buildMembershipQuery(config: OrgSchemaConfig): string { + const m = qualified(config.membersTable); + + const orgExternalIdExpr = config.orgExternalIdColumn + ? format('o.%I::text', config.orgExternalIdColumn) + : format('m.%I::text', config.membershipOrgColumn); + + const roleExpr = config.membershipRoleColumn + ? format('m.%I::text', config.membershipRoleColumn) + : 'NULL::text'; + + const o = qualified(config.orgTable); + + return format( + 'SELECT u.email AS email, u.id::text AS user_external_id, %s AS org_external_id, %s AS role FROM %I.%I m JOIN auth.users u ON u.id = m.%I JOIN %I.%I o ON o.%I = m.%I', + orgExternalIdExpr, + roleExpr, + m.schema, + m.name, + config.membershipUserColumn, + o.schema, + o.name, + config.orgIdColumn, + config.membershipOrgColumn, + ); +} + +export function buildOrphanMembershipCountQuery(config: OrgSchemaConfig): string { + const m = qualified(config.membersTable); + return format( + 'SELECT count(*)::int AS count FROM %I.%I m LEFT JOIN auth.users u ON u.id = m.%I WHERE u.id IS NULL', + m.schema, + m.name, + config.membershipUserColumn, + ); +} diff --git a/src/exporters/supabase/org-schema.ts b/src/exporters/supabase/org-schema.ts new file mode 100644 index 0000000..bcf086a --- /dev/null +++ b/src/exporters/supabase/org-schema.ts @@ -0,0 +1,95 @@ +import type { OrgSchemaConfig, QualifiedIdentifier } from '../../shared/types.js'; + +const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export interface OrgSchemaFlags { + orgTable?: string; + orgIdColumn?: string; + orgNameColumn?: string; + orgExternalIdColumn?: string; + orgDomainsColumn?: string; + membersTable?: string; + membershipUserColumn?: string; + membershipOrgColumn?: string; + membershipRoleColumn?: string; + roleSlugMapPath?: string; +} + +const REQUIRED_FLAGS = [ + 'orgTable', + 'orgIdColumn', + 'orgNameColumn', + 'membersTable', + 'membershipUserColumn', + 'membershipOrgColumn', +] as const satisfies readonly (keyof OrgSchemaFlags)[]; + +export function parseQualifiedIdentifier(input: string): QualifiedIdentifier { + const parts = input.split('.'); + if (parts.length === 1) { + assertSafeIdentifier(parts[0], input); + return { name: parts[0] }; + } + if (parts.length === 2) { + assertSafeIdentifier(parts[0], input); + assertSafeIdentifier(parts[1], input); + return { schema: parts[0], name: parts[1] }; + } + throw new Error(`Invalid identifier: ${input}`); +} + +export function assertSafeIdentifier(value: string, context: string): void { + if (!SAFE_IDENTIFIER.test(value)) { + throw new Error(`Invalid identifier: ${context}`); + } +} + +/** + * Validate the group of org/membership CLI flags. Returns null when none of the + * required-group flags are present (org export not requested). Throws when any + * but not all required flags are supplied. + */ +export function validateOrgSchemaFlags(flags: OrgSchemaFlags): OrgSchemaConfig | null { + const supplied = REQUIRED_FLAGS.filter((key) => flags[key] !== undefined && flags[key] !== ''); + if (supplied.length === 0) return null; + if (supplied.length < REQUIRED_FLAGS.length) { + const missing = REQUIRED_FLAGS.filter((key) => !supplied.includes(key)); + throw new Error( + `Incomplete org schema flags. Required flags must all be provided together: ${REQUIRED_FLAGS.join(', ')}. Missing: ${missing.join(', ')}`, + ); + } + + const config: OrgSchemaConfig = { + orgTable: parseQualifiedIdentifier(flags.orgTable!), + orgIdColumn: validateColumn(flags.orgIdColumn!, '--org-id-column'), + orgNameColumn: validateColumn(flags.orgNameColumn!, '--org-name-column'), + membersTable: parseQualifiedIdentifier(flags.membersTable!), + membershipUserColumn: validateColumn(flags.membershipUserColumn!, '--membership-user-column'), + membershipOrgColumn: validateColumn(flags.membershipOrgColumn!, '--membership-org-column'), + }; + + if (flags.orgExternalIdColumn) { + config.orgExternalIdColumn = validateColumn(flags.orgExternalIdColumn, '--org-external-id-column'); + } + if (flags.orgDomainsColumn) { + config.orgDomainsColumn = validateColumn(flags.orgDomainsColumn, '--org-domains-column'); + } + if (flags.membershipRoleColumn) { + config.membershipRoleColumn = validateColumn( + flags.membershipRoleColumn, + '--membership-role-column', + ); + } + if (flags.roleSlugMapPath) { + config.roleSlugMapPath = flags.roleSlugMapPath; + } + + return config; +} + +function validateColumn(value: string, flagName: string): string { + if (!SAFE_IDENTIFIER.test(value)) { + throw new Error(`Invalid identifier for ${flagName}: ${value}`); + } + return value; +} diff --git a/src/exporters/supabase/package-writer.ts b/src/exporters/supabase/package-writer.ts index 2fe88d8..284b8cf 100644 --- a/src/exporters/supabase/package-writer.ts +++ b/src/exporters/supabase/package-writer.ts @@ -3,6 +3,8 @@ import { createCSVWriter } from '../../shared/csv-utils.js'; import { USER_CSV_HEADERS, TOTP_SECRET_CSV_HEADERS, + ORGANIZATION_CSV_HEADERS, + ORGANIZATION_MEMBERSHIP_CSV_HEADERS, createMigrationPackageManifest, } from '../../package/manifest.js'; import { @@ -23,6 +25,8 @@ export interface SupabaseWriterContext { writeUser: (row: CSVRow) => void; writeTotpRecords: (records: TotpRecord[]) => Promise; writeSamlConnections: (rows: SamlRowInput[]) => Promise; + writeOrganizations: (rows: Record[]) => Promise; + writeMemberships: (rows: Record[]) => Promise; finalize: (options: { url: string; entitiesRequested: string[] }) => Promise; } @@ -53,6 +57,9 @@ export async function openSupabasePackage(outputDir: string): Promise[]) { + if (rows.length === 0) return; + await writePackageCsvRows(rootDir, 'organizations', rows, ORGANIZATION_CSV_HEADERS); + stats.orgsExported += rows.length; + }, + async writeMemberships(rows: Record[]) { + if (rows.length === 0) return; + await writePackageCsvRows(rootDir, 'memberships', rows, ORGANIZATION_MEMBERSHIP_CSV_HEADERS); + stats.membershipsExported += rows.length; + }, async finalize(options) { await usersWriter.end(); @@ -92,8 +109,8 @@ export async function openSupabasePackage(outputDir: string): Promise; + +export interface ApplyRoleSlugMapResult { + slug?: string; + warning?: string; +} + +/** + * Load a role-slug map from JSON (object dict) or CSV (`role,slug` columns). + * Format is detected by file extension; throws when the file doesn't exist. + */ +export async function loadRoleSlugMap(filePath: string): Promise { + const resolved = path.resolve(filePath); + const ext = path.extname(resolved).toLowerCase(); + + if (ext === '.json') return loadJson(resolved); + if (ext === '.csv') return loadCsv(resolved); + throw new Error(`Unsupported role-slug-map extension: ${ext || ''} (expected .json or .csv)`); +} + +export function applyRoleSlugMap( + map: RoleSlugMap | undefined, + raw: string | null | undefined, +): ApplyRoleSlugMapResult { + if (!raw) return {}; + const trimmed = raw.trim(); + if (!trimmed) return {}; + + if (!map) return { slug: trimmed }; + + const slug = map.get(trimmed); + if (slug) return { slug }; + + return { warning: `Unmapped role: ${trimmed}` }; +} + +async function loadJson(filePath: string): Promise { + const raw = await fsp.readFile(filePath, 'utf-8'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + throw new Error(`Failed to parse role-slug-map JSON at ${filePath}: ${(error as Error).message}`, { + cause: error, + }); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Role-slug-map JSON must be an object dict: ${filePath}`); + } + + const map: RoleSlugMap = new Map(); + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value !== 'string') { + throw new Error(`Role-slug-map JSON value for "${key}" must be a string`); + } + map.set(key, value); + } + return map; +} + +async function loadCsv(filePath: string): Promise { + return new Promise((resolve, reject) => { + const map: RoleSlugMap = new Map(); + const parser = parse({ columns: true, skip_empty_lines: true, trim: true }); + createReadStream(filePath) + .pipe(parser) + .on('data', (row: Record) => { + if (!('role' in row) || !('slug' in row)) { + parser.destroy( + new Error(`Role-slug-map CSV must have columns "role" and "slug": ${filePath}`), + ); + return; + } + const role = row.role?.trim(); + const slug = row.slug?.trim(); + if (role && slug) map.set(role, slug); + }) + .on('end', () => resolve(map)) + .on('error', reject); + }); +} diff --git a/src/exporters/supabase/types.ts b/src/exporters/supabase/types.ts index 6ece74d..1bff971 100644 --- a/src/exporters/supabase/types.ts +++ b/src/exporters/supabase/types.ts @@ -50,6 +50,9 @@ export interface SupabaseExportStats { skipped: number; totpExported: number; samlExported: number; + orgsExported: number; + membershipsExported: number; + orphanMemberships: number; warnings: string[]; skippedRecords: SupabaseSkippedRecord[]; } diff --git a/src/providers/supabase/index.ts b/src/providers/supabase/index.ts index 76233c5..094715d 100644 --- a/src/providers/supabase/index.ts +++ b/src/providers/supabase/index.ts @@ -55,7 +55,7 @@ export const supabaseProvider: Provider = { key: 'organizations', name: 'Organizations', description: 'From user-supplied org table — requires Postgres connection', - enabled: false, + enabled: true, }, ], }; diff --git a/src/shared/types.ts b/src/shared/types.ts index b5b79c1..823caf5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -131,6 +131,24 @@ export interface Auth0ExportOptions { // --- Supabase Types --- +export interface QualifiedIdentifier { + schema?: string; + name: string; +} + +export interface OrgSchemaConfig { + orgTable: QualifiedIdentifier; + orgIdColumn: string; + orgNameColumn: string; + orgExternalIdColumn?: string; + orgDomainsColumn?: string; + membersTable: QualifiedIdentifier; + membershipUserColumn: string; + membershipOrgColumn: string; + membershipRoleColumn?: string; + roleSlugMapPath?: string; +} + export interface SupabaseExportOptions { url: string; serviceRoleKey: string; @@ -140,6 +158,7 @@ export interface SupabaseExportOptions { rateLimit: number; pageSize: number; totpIssuer?: string; + orgSchema?: OrgSchemaConfig; quiet: boolean; } diff --git a/tests/providers/supabase/exporter-orgs.integration.test.ts b/tests/providers/supabase/exporter-orgs.integration.test.ts new file mode 100644 index 0000000..5c2e5c8 --- /dev/null +++ b/tests/providers/supabase/exporter-orgs.integration.test.ts @@ -0,0 +1,235 @@ +import { jest } from '@jest/globals'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { exportSupabase } from '../../../src/exporters/supabase/exporter.js'; +import type { SupabasePgQueryClient } from '../../../src/exporters/supabase/pg-client.js'; +import type { OrgSchemaConfig } from '../../../src/shared/types.js'; +import { streamCSV } from '../../../src/shared/csv-utils.js'; + +const FIXTURES_DIR = path.resolve(process.cwd(), 'tests/providers/supabase/fixtures'); + +interface FakePgState { + orgRows?: Array>; + membershipRows?: Array>; + orphanCount?: number; +} + +function fakePgClient(state: FakePgState): SupabasePgQueryClient { + return { + async testConnection() {}, + async query(sql: string): Promise { + if (/AS org_internal_id/.test(sql)) return (state.orgRows ?? []) as T[]; + if (/u\.email AS email/.test(sql)) return (state.membershipRows ?? []) as T[]; + if (/LEFT JOIN auth\.users/.test(sql)) { + return [{ count: state.orphanCount ?? 0 }] as unknown as T[]; + } + return [] as T[]; + }, + async close() {}, + }; +} + +const WORKSPACES_SCHEMA: OrgSchemaConfig = { + orgTable: { schema: 'public', name: 'workspaces' }, + orgIdColumn: 'id', + orgNameColumn: 'name', + orgExternalIdColumn: 'slug', + orgDomainsColumn: 'domain', + membersTable: { schema: 'public', name: 'workspace_members' }, + membershipUserColumn: 'user_id', + membershipOrgColumn: 'workspace_id', + membershipRoleColumn: 'role', +}; + +describe('exportSupabase end-to-end with organizations', () => { + let tmpDir: string; + let fetchMock: jest.MockedFunction; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supabase-export-orgs-test-')); + fetchMock = jest.fn() as jest.MockedFunction; + global.fetch = fetchMock; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it('produces organizations.csv + organization_memberships.csv with role mappings', async () => { + const roleMapPath = path.join(tmpDir, 'roles.json'); + await fsp.writeFile(roleMapPath, JSON.stringify({ owner: 'admin', member: 'member' })); + + const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); + const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ users: [page1.users[0]] })) + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(empty)); + + await exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + dbUrl: 'postgresql://x:y@db.example.com:5432/postgres', + outputDir: tmpDir, + entities: ['users', 'organizations'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + orgSchema: { ...WORKSPACES_SCHEMA, roleSlugMapPath: roleMapPath }, + pgClientFactory: () => + fakePgClient({ + orgRows: [ + { + org_internal_id: 'uuid-acme', + org_name: 'Acme', + org_external_id: 'acme', + org_domains: ['acme.com', 'acme.io'], + }, + { + org_internal_id: 'uuid-globex', + org_name: 'Globex', + org_external_id: 'globex', + org_domains: null, + }, + ], + membershipRows: [ + { + email: 'alice@example.com', + user_external_id: '11111111-1111-1111-1111-111111111111', + org_external_id: 'acme', + role: 'owner', + }, + { + email: 'bob.builder@example.com', + user_external_id: '22222222-2222-2222-2222-222222222222', + org_external_id: 'acme', + role: 'member', + }, + ], + orphanCount: 0, + }), + }); + + const orgs = await readCsvRows(path.join(tmpDir, 'organizations.csv')); + expect(orgs).toHaveLength(2); + expect(orgs.find((o) => o.org_external_id === 'acme')?.domains).toBe('acme.com,acme.io'); + expect(orgs.find((o) => o.org_external_id === 'globex')?.domains).toBe(''); + + const memberships = await readCsvRows(path.join(tmpDir, 'organization_memberships.csv')); + expect(memberships).toHaveLength(2); + expect(memberships[0].role_slugs).toBe('admin'); + expect(memberships[1].role_slugs).toBe('member'); + + const manifest = JSON.parse(await fsp.readFile(path.join(tmpDir, 'manifest.json'), 'utf-8')); + expect(manifest.entitiesExported.organizations).toBe(2); + expect(manifest.entitiesExported.memberships).toBe(2); + }); + + it('warns and skips org export when organizations is requested without schema flags', async () => { + const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); + const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ users: [page1.users[0]] })) + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(empty)); + + await exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + dbUrl: 'postgresql://x:y@db.example.com:5432/postgres', + outputDir: tmpDir, + entities: ['users', 'organizations'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + pgClientFactory: () => fakePgClient({}), + }); + + const orgs = await readCsvRows(path.join(tmpDir, 'organizations.csv')); + expect(orgs).toHaveLength(0); + + const warnings = fs.readFileSync(path.join(tmpDir, 'warnings.jsonl'), 'utf-8'); + expect(warnings).toMatch(/no org schema flags supplied/); + }); + + it('emits an unmapped-role warning when every DB role is missing from the slug map', async () => { + const roleMapPath = path.join(tmpDir, 'roles.json'); + await fsp.writeFile(roleMapPath, JSON.stringify({ admin: 'admin' })); + + const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); + const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ users: [page1.users[0]] })) + .mockResolvedValueOnce(jsonResponse(page1)) + .mockResolvedValueOnce(jsonResponse(empty)); + + await exportSupabase({ + url: 'https://demo.supabase.co', + serviceRoleKey: 'sb-service-role-jwt', + dbUrl: 'postgresql://x:y@db.example.com:5432/postgres', + outputDir: tmpDir, + entities: ['users', 'organizations'], + rateLimit: 1000, + pageSize: 100, + quiet: true, + orgSchema: { ...WORKSPACES_SCHEMA, roleSlugMapPath: roleMapPath }, + pgClientFactory: () => + fakePgClient({ + orgRows: [ + { + org_internal_id: 'uuid-acme', + org_name: 'Acme', + org_external_id: 'acme', + org_domains: null, + }, + ], + membershipRows: [ + { + email: 'a@example.com', + user_external_id: 'u1', + org_external_id: 'acme', + role: 'owner', + }, + { + email: 'b@example.com', + user_external_id: 'u2', + org_external_id: 'acme', + role: 'guest', + }, + ], + }), + }); + + const memberships = await readCsvRows(path.join(tmpDir, 'organization_memberships.csv')); + expect(memberships).toHaveLength(2); + expect(memberships.every((m) => m.role_slugs === '')).toBe(true); + + const warnings = fs.readFileSync(path.join(tmpDir, 'warnings.jsonl'), 'utf-8'); + expect(warnings).toMatch(/Unmapped role: owner/); + expect(warnings).toMatch(/Unmapped role: guest/); + }); +}); + +async function readCsvRows(filePath: string): Promise[]> { + const rows: Record[] = []; + for await (const row of streamCSV(filePath)) { + rows.push(row as Record); + } + return rows; +} + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} From a904593b34c3792acbdf119bd256878b0ccc2c57 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 19 May 2026 12:14:32 -0700 Subject: [PATCH 4/6] =?UTF-8?q?feat(supabase):=20Phase=204=20=E2=80=94=20w?= =?UTF-8?q?izard=20integration=20and=20shared=20splitDisplayName?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Supabase Auth into the interactive `wizard` command end-to-end: provider menu → entity-and-org-schema config → export → conditional password merge → validation → import-package. Credentials are read from SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and SUPABASE_DB_URL env vars (mirroring the Cognito pattern); no new prompts in credentials.ts. - Provider union and WizardState extended with 16 supabase* fields (URL, service-role key, DB URL, package dir, entities, TOTP issuer, plus the ten org-schema flag fields). Password-merge gate updated to fire for Auth0 or for Supabase-with-DB-URL. - provider-selection.ts gets a Supabase entry between Cognito and Custom CSV. - export-config.ts adds configureSupabaseExport with a conditional-prompts cascade (multiselect entities, optional org schema flags, optional role-slug map path). Default entity selection promotes mfa/sso when SUPABASE_DB_URL is set. `users` is always force-included so downstream csvFilePath resolves. - export-run.ts adds runSupabaseExport that assembles an OrgSchemaConfig via validateOrgSchemaFlags (rejecting invalid identifiers pre-connect), then calls exportSupabase. Errors print the standalone-CLI retry hint matching the Auth0/Cognito convention. - password-merge.ts refactored to dispatch on state.provider — Auth0 keeps its NDJSON path prompt; Supabase asks a single "Merge from Postgres now?" confirm and calls mergeSupabasePasswords. The Auth0 logic is unchanged. - src/shared/name-split.ts lifts splitDisplayName so the Firebase transformer and Supabase user-mapper share one implementation. The local copy in user-mapper.ts is removed; the Supabase call site now passes 'first-space' explicitly (preserving Phase 1 behavior). The Firebase test imports from the lifted module directly — no re-export shim. - README "Migrating from Supabase Auth" status banner updated to reflect full coverage. package.json keywords gains "supabase". - Full suite 367/367 passing; no new tests in this phase (wizard test infrastructure didn't exist before Phase 4 and creating it was out of scope per the spec deviation noted in the completion report). Review cycle 1: PASS, 2 medium + 2 low findings auto-fixed (force-include users entity; split SUPABASE_DB_URL vs package-dir skip messages; drop the vestigial SplitName interface and the firebase splitDisplayName re-export). Scope cuts (not findings — accepted constraints): - credentials.ts not modified (permission-blocked during build). Supabase reads credentials from env vars instead. Aligns with Cognito's pattern. - src/wizard/__tests__ directory and tests/providers/supabase/wizard. integration.test.ts not created — wizard test infrastructure introduction deferred to a follow-up. --- README.md | 2 +- package.json | 3 +- src/exporters/supabase/user-mapper.ts | 18 +-- src/shared/name-split.ts | 34 +++++ src/transformers/__tests__/firebase.test.ts | 3 +- src/transformers/firebase/transformer.ts | 32 +--- src/wizard/steps/export-config.ts | 160 ++++++++++++++++++++ src/wizard/steps/export-run.ts | 54 ++++++- src/wizard/steps/password-merge.ts | 69 +++++++++ src/wizard/steps/provider-selection.ts | 5 + src/wizard/wizard.ts | 25 ++- 11 files changed, 352 insertions(+), 53 deletions(-) create mode 100644 src/shared/name-split.ts diff --git a/README.md b/README.md index 5edadbd..433ddfd 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ Continue to [Validation](#validation), [Import](#importing-users), and [Post-Imp ## Migrating from Supabase Auth -> **Status — Phase 3**: users + OAuth identities via the Admin API; bcrypt password hashes, TOTP MFA factors, SAML SSO connections, and organizations/role assignments via direct Postgres. +Full Supabase Auth migration support: users + OAuth identities via the Admin API; bcrypt password hashes, TOTP MFA factors, SAML SSO connections, and organizations + role assignments via direct Postgres. Available end-to-end via the interactive wizard (`workos-migrate wizard` → choose Supabase Auth) or the individual CLI commands below. ### 1. Set up Supabase credentials diff --git a/package.json b/package.json index 9467a3c..00ea765 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "auth0", "clerk", "firebase", - "cognito" + "cognito", + "supabase" ], "author": "WorkOS", "license": "MIT", diff --git a/src/exporters/supabase/user-mapper.ts b/src/exporters/supabase/user-mapper.ts index 74f462b..eaa9b15 100644 --- a/src/exporters/supabase/user-mapper.ts +++ b/src/exporters/supabase/user-mapper.ts @@ -1,4 +1,5 @@ import type { CSVRow } from '../../shared/types.js'; +import { splitDisplayName } from '../../shared/name-split.js'; import type { SupabaseAdminUser, SupabaseIdentity } from './types.js'; export interface MappedSupabaseUser { @@ -8,25 +9,12 @@ export interface MappedSupabaseUser { warnings: string[]; } -interface SplitName { - firstName: string; - lastName: string; -} - -function splitDisplayName(displayName: string | undefined): SplitName { - if (!displayName?.trim()) return { firstName: '', lastName: '' }; - const name = displayName.trim(); - const idx = name.indexOf(' '); - if (idx === -1) return { firstName: name, lastName: '' }; - return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) }; -} - function readStringField(source: Record | undefined, key: string): string | undefined { const value = source?.[key]; return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; } -function resolveNames(user: SupabaseAdminUser): SplitName { +function resolveNames(user: SupabaseAdminUser): { firstName: string; lastName: string } { const meta = user.user_metadata; const explicitFirst = readStringField(meta, 'first_name'); const explicitLast = readStringField(meta, 'last_name'); @@ -35,7 +23,7 @@ function resolveNames(user: SupabaseAdminUser): SplitName { } const fullName = readStringField(meta, 'full_name') ?? readStringField(meta, 'name'); - if (fullName) return splitDisplayName(fullName); + if (fullName) return splitDisplayName(fullName, 'first-space'); return { firstName: '', lastName: '' }; } diff --git a/src/shared/name-split.ts b/src/shared/name-split.ts new file mode 100644 index 0000000..3d44c26 --- /dev/null +++ b/src/shared/name-split.ts @@ -0,0 +1,34 @@ +import type { NameSplitStrategy } from './types.js'; + +/** + * Split a display name into first and last name using the given strategy. + * Used by Firebase and Supabase exporters to normalize `display_name` / + * `user_metadata.full_name` into first/last columns. + */ +export function splitDisplayName( + displayName: string | undefined, + strategy: NameSplitStrategy, +): { firstName: string; lastName: string } { + if (!displayName?.trim()) { + return { firstName: '', lastName: '' }; + } + + const name = displayName.trim(); + + switch (strategy) { + case 'first-space': { + const idx = name.indexOf(' '); + if (idx === -1) return { firstName: name, lastName: '' }; + return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) }; + } + case 'last-space': { + const idx = name.lastIndexOf(' '); + if (idx === -1) return { firstName: name, lastName: '' }; + return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) }; + } + case 'first-name-only': + return { firstName: name, lastName: '' }; + default: + return { firstName: name, lastName: '' }; + } +} diff --git a/src/transformers/__tests__/firebase.test.ts b/src/transformers/__tests__/firebase.test.ts index d1d8817..83617fa 100644 --- a/src/transformers/__tests__/firebase.test.ts +++ b/src/transformers/__tests__/firebase.test.ts @@ -12,8 +12,9 @@ jest.unstable_mockModule('../../shared/logger.js', () => ({ debug: jest.fn(), })); -const { transformFirebaseExport, splitDisplayName } = await import('../firebase/transformer.js'); +const { transformFirebaseExport } = await import('../firebase/transformer.js'); const { encodeFirebaseScryptPHC } = await import('../firebase/scrypt.js'); +const { splitDisplayName } = await import('../../shared/name-split.js'); describe('Firebase Transformer', () => { let tmpDir: string; diff --git a/src/transformers/firebase/transformer.ts b/src/transformers/firebase/transformer.ts index 2fe3fe5..34d9b5d 100644 --- a/src/transformers/firebase/transformer.ts +++ b/src/transformers/firebase/transformer.ts @@ -12,6 +12,7 @@ import type { OrgMappingRow } from '../shared/org-mapper.js'; import { loadOrgMapping, applyOrgMapping, buildOutputColumns } from '../shared/org-mapper.js'; import { loadRoleMapping } from '../shared/role-mapper.js'; import { encodeFirebaseScryptPHC } from './scrypt.js'; +import { splitDisplayName } from '../../shared/name-split.js'; import * as logger from '../../shared/logger.js'; export interface FirebaseTransformOptions { @@ -26,37 +27,6 @@ export interface FirebaseTransformOptions { quiet?: boolean; } -/** - * Split a display name into first and last name using the given strategy. - */ -export function splitDisplayName( - displayName: string | undefined, - strategy: NameSplitStrategy, -): { firstName: string; lastName: string } { - if (!displayName?.trim()) { - return { firstName: '', lastName: '' }; - } - - const name = displayName.trim(); - - switch (strategy) { - case 'first-space': { - const idx = name.indexOf(' '); - if (idx === -1) return { firstName: name, lastName: '' }; - return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) }; - } - case 'last-space': { - const idx = name.lastIndexOf(' '); - if (idx === -1) return { firstName: name, lastName: '' }; - return { firstName: name.slice(0, idx), lastName: name.slice(idx + 1) }; - } - case 'first-name-only': - return { firstName: name, lastName: '' }; - default: - return { firstName: name, lastName: '' }; - } -} - function msEpochToISO(msString: string): string | undefined { const ms = parseInt(msString, 10); if (isNaN(ms)) return undefined; diff --git a/src/wizard/steps/export-config.ts b/src/wizard/steps/export-config.ts index 33c43fa..4751ad3 100644 --- a/src/wizard/steps/export-config.ts +++ b/src/wizard/steps/export-config.ts @@ -18,6 +18,9 @@ export async function configureExport(state: WizardState): Promise if (state.provider === 'cognito') { return configureCognitoExport(state); } + if (state.provider === 'supabase') { + return configureSupabaseExport(state); + } if (state.provider === 'csv') { return configureCustomCsv(state); } @@ -361,3 +364,160 @@ async function configureCustomCsv(state: WizardState): Promise { state.csvFilePath = response.csvPath; return state; } + +async function configureSupabaseExport(state: WizardState): Promise { + console.log(chalk.gray(' Supabase credentials are read from environment variables:')); + console.log(chalk.gray(' SUPABASE_URL (required)')); + console.log(chalk.gray(' SUPABASE_SERVICE_ROLE_KEY (required)')); + console.log(chalk.gray(' SUPABASE_DB_URL (optional — needed for passwords/MFA/SSO/orgs)\n')); + + const url = process.env.SUPABASE_URL?.trim(); + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + const dbUrl = process.env.SUPABASE_DB_URL?.trim(); + + if (!url || !serviceRoleKey) { + console.error( + chalk.red( + ' Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY. Set them and re-run the wizard.', + ), + ); + state.cancelled = true; + return state; + } + + state.supabaseUrl = url; + state.supabaseServiceRoleKey = serviceRoleKey; + state.supabaseDbUrl = dbUrl || undefined; + + const hasDb = Boolean(dbUrl); + + const response = await prompts( + [ + { + type: 'multiselect', + name: 'entities', + message: 'Which entities should be exported?', + choices: [ + { title: 'Users', value: 'users', selected: true }, + { title: 'OAuth identities (stored in user metadata)', value: 'identities', selected: true }, + { title: 'TOTP MFA factors (requires SUPABASE_DB_URL)', value: 'mfa', selected: hasDb }, + { title: 'SAML SSO connections (requires SUPABASE_DB_URL)', value: 'sso', selected: hasDb }, + { title: 'Organizations + memberships (requires SUPABASE_DB_URL + schema flags)', value: 'organizations', selected: false }, + ], + min: 1, + }, + { + type: 'text', + name: 'outputDir', + message: 'Output directory for the migration package', + initial: './migration-supabase', + }, + { + type: (_: unknown, values: Record) => + Array.isArray(values.entities) && (values.entities as string[]).includes('mfa') ? 'text' : null, + name: 'totpIssuer', + message: 'TOTP issuer label (shown in authenticator apps)', + initial: 'Supabase', + }, + { + type: (_: unknown, values: Record) => + Array.isArray(values.entities) && (values.entities as string[]).includes('organizations') + ? 'text' + : null, + name: 'orgTable', + message: 'Org table (e.g., public.organizations)', + validate: (v: string) => v.length > 0 || 'Required', + }, + { + type: (_: unknown, values: Record) => + values.orgTable ? 'text' : null, + name: 'orgIdColumn', + message: 'Org id column', + initial: 'id', + }, + { + type: (_: unknown, values: Record) => + values.orgTable ? 'text' : null, + name: 'orgNameColumn', + message: 'Org name column', + initial: 'name', + }, + { + type: (_: unknown, values: Record) => + values.orgTable ? 'text' : null, + name: 'orgExternalIdColumn', + message: 'Org external_id column (leave blank to use the id column)', + initial: '', + }, + { + type: (_: unknown, values: Record) => + values.orgTable ? 'text' : null, + name: 'orgDomainsColumn', + message: 'Org domains column (leave blank if not stored)', + initial: '', + }, + { + type: (_: unknown, values: Record) => + values.orgTable ? 'text' : null, + name: 'membersTable', + message: 'Members table (e.g., public.org_members)', + validate: (v: string) => v.length > 0 || 'Required', + }, + { + type: (_: unknown, values: Record) => + values.membersTable ? 'text' : null, + name: 'membershipUserColumn', + message: 'Membership user_id column', + initial: 'user_id', + }, + { + type: (_: unknown, values: Record) => + values.membersTable ? 'text' : null, + name: 'membershipOrgColumn', + message: 'Membership org_id column', + initial: 'organization_id', + }, + { + type: (_: unknown, values: Record) => + values.membersTable ? 'text' : null, + name: 'membershipRoleColumn', + message: 'Membership role column (leave blank if not stored)', + initial: 'role', + }, + { + type: (_: unknown, values: Record) => + values.membershipRoleColumn ? 'text' : null, + name: 'roleSlugMapPath', + message: 'Path to --role-slug-map JSON or CSV (leave blank to pass DB roles through verbatim)', + initial: '', + validate: (v: string) => !v || fs.existsSync(v) || 'File not found', + }, + ], + { + onCancel: () => { + state.cancelled = true; + }, + }, + ); + + if (state.cancelled) return state; + + // Force-include 'users' — without it there's no users.csv to drive downstream validation. + const entities = Array.isArray(response.entities) ? response.entities : []; + state.supabaseEntities = entities.includes('users') ? entities : ['users', ...entities]; + state.supabasePackageDir = response.outputDir; + state.supabaseTotpIssuer = response.totpIssuer || undefined; + state.supabaseOrgTable = response.orgTable || undefined; + state.supabaseOrgIdColumn = response.orgIdColumn || undefined; + state.supabaseOrgNameColumn = response.orgNameColumn || undefined; + state.supabaseOrgExternalIdColumn = response.orgExternalIdColumn || undefined; + state.supabaseOrgDomainsColumn = response.orgDomainsColumn || undefined; + state.supabaseMembersTable = response.membersTable || undefined; + state.supabaseMembershipUserColumn = response.membershipUserColumn || undefined; + state.supabaseMembershipOrgColumn = response.membershipOrgColumn || undefined; + state.supabaseMembershipRoleColumn = response.membershipRoleColumn || undefined; + state.supabaseRoleSlugMapPath = response.roleSlugMapPath || undefined; + state.csvFilePath = `${response.outputDir}/users.csv`; + + return state; +} diff --git a/src/wizard/steps/export-run.ts b/src/wizard/steps/export-run.ts index de58849..08ce0a2 100644 --- a/src/wizard/steps/export-run.ts +++ b/src/wizard/steps/export-run.ts @@ -4,7 +4,9 @@ import { exportAuth0 } from '../../exporters/auth0/exporter.js'; import { transformClerkExport } from '../../transformers/clerk/transformer.js'; import { transformFirebaseExport } from '../../transformers/firebase/transformer.js'; import { CognitoClient } from '../../providers/cognito/index.js'; -import type { FirebaseScryptConfig } from '../../shared/types.js'; +import { exportSupabase } from '../../exporters/supabase/exporter.js'; +import { validateOrgSchemaFlags } from '../../exporters/supabase/org-schema.js'; +import type { FirebaseScryptConfig, OrgSchemaConfig } from '../../shared/types.js'; export async function runExport(state: WizardState): Promise { console.log(chalk.cyan(' Step 4: Export / Transform\n')); @@ -26,6 +28,9 @@ export async function runExport(state: WizardState): Promise { if (state.provider === 'cognito') { return runCognitoExport(state); } + if (state.provider === 'supabase') { + return runSupabaseExport(state); + } return state; } @@ -222,3 +227,50 @@ async function runCognitoExport(state: WizardState): Promise { return state; } + +async function runSupabaseExport(state: WizardState): Promise { + console.log(chalk.blue(' Exporting Supabase migration package...\n')); + + let orgSchema: OrgSchemaConfig | null; + try { + orgSchema = validateOrgSchemaFlags({ + orgTable: state.supabaseOrgTable, + orgIdColumn: state.supabaseOrgIdColumn, + orgNameColumn: state.supabaseOrgNameColumn, + orgExternalIdColumn: state.supabaseOrgExternalIdColumn, + orgDomainsColumn: state.supabaseOrgDomainsColumn, + membersTable: state.supabaseMembersTable, + membershipUserColumn: state.supabaseMembershipUserColumn, + membershipOrgColumn: state.supabaseMembershipOrgColumn, + membershipRoleColumn: state.supabaseMembershipRoleColumn, + roleSlugMapPath: state.supabaseRoleSlugMapPath, + }); + } catch (err) { + console.error(chalk.red(`\n Invalid org schema flags: ${(err as Error).message}`)); + state.cancelled = true; + return state; + } + + try { + await exportSupabase({ + url: state.supabaseUrl!, + serviceRoleKey: state.supabaseServiceRoleKey!, + dbUrl: state.supabaseDbUrl, + outputDir: state.supabasePackageDir!, + entities: state.supabaseEntities ?? ['users'], + rateLimit: 50, + pageSize: 1000, + totpIssuer: state.supabaseTotpIssuer, + orgSchema: orgSchema ?? undefined, + quiet: false, + }); + + console.log(chalk.green(`\n Package export complete: ${state.supabasePackageDir}\n`)); + } catch (err) { + console.error(chalk.red(`\n Supabase export failed: ${(err as Error).message}`)); + console.log(chalk.gray(' You can retry with: workos-migrate export-supabase --package\n')); + state.cancelled = true; + } + + return state; +} diff --git a/src/wizard/steps/password-merge.ts b/src/wizard/steps/password-merge.ts index cf7984b..1b161d5 100644 --- a/src/wizard/steps/password-merge.ts +++ b/src/wizard/steps/password-merge.ts @@ -7,10 +7,19 @@ import { mergePasswordsIntoCsv, mergePasswordsIntoPackage, } from '../../exporters/auth0/password-merger.js'; +import { mergeSupabasePasswords } from '../../exporters/supabase/password-merger.js'; export async function mergePasswords(state: WizardState): Promise { console.log(chalk.cyan(' Step 5: Password Hash Merge\n')); + if (state.provider === 'supabase') { + return mergeSupabasePasswordsStep(state); + } + + return mergeAuth0Passwords(state); +} + +async function mergeAuth0Passwords(state: WizardState): Promise { const response = await prompts( { type: 'confirm', @@ -101,3 +110,63 @@ export async function mergePasswords(state: WizardState): Promise { return state; } + +async function mergeSupabasePasswordsStep(state: WizardState): Promise { + if (!state.supabaseDbUrl) { + console.log(chalk.gray(' Skipping password merge (SUPABASE_DB_URL not set).\n')); + return state; + } + if (!state.supabasePackageDir) { + console.log(chalk.gray(' Skipping password merge (Supabase package directory not configured).\n')); + return state; + } + + const response = await prompts( + { + type: 'confirm', + name: 'mergeNow', + message: 'Merge bcrypt password hashes from Supabase Postgres into the package?', + initial: true, + }, + { + onCancel: () => { + state.cancelled = true; + }, + }, + ); + + if (state.cancelled) return state; + + if (!response.mergeNow) { + console.log(chalk.gray(' Skipping password merge.')); + console.log( + chalk.gray( + ' You can run it later with: workos-migrate merge-passwords-supabase --package --db-url \n', + ), + ); + return state; + } + + console.log(chalk.blue('\n Querying auth.users.encrypted_password...')); + + try { + const stats = await mergeSupabasePasswords({ + packageDir: state.supabasePackageDir, + dbUrl: state.supabaseDbUrl, + quiet: false, + }); + + console.log(chalk.green('\n Merge complete')); + console.log(` Total rows: ${stats.totalRows}`); + console.log(` Matched: ${stats.matched}`); + console.log(` Missing in DB: ${stats.missing}`); + console.log(` Unsupported algorithm: ${stats.unsupportedAlgo}`); + console.log(` workos_upload rows updated: ${stats.uploadRowsUpdated}`); + console.log(` Warnings: ${stats.warnings.length}\n`); + } catch (err) { + console.error(chalk.red(`\n Password merge failed: ${(err as Error).message}`)); + console.log(chalk.gray(' Continuing without passwords.\n')); + } + + return state; +} diff --git a/src/wizard/steps/provider-selection.ts b/src/wizard/steps/provider-selection.ts index 2e4cc4a..80b03f5 100644 --- a/src/wizard/steps/provider-selection.ts +++ b/src/wizard/steps/provider-selection.ts @@ -31,6 +31,11 @@ export async function selectProvider(state: WizardState): Promise { value: 'cognito', description: 'Export users + SSO connections from Cognito user pools', }, + { + title: 'Supabase Auth', + value: 'supabase', + description: 'Admin API for users + optional Postgres for passwords/MFA/SSO/orgs', + }, { title: 'Custom CSV', value: 'csv', description: 'Already have a WorkOS-formatted CSV' }, ], }, diff --git a/src/wizard/wizard.ts b/src/wizard/wizard.ts index 87d4e55..60d0474 100644 --- a/src/wizard/wizard.ts +++ b/src/wizard/wizard.ts @@ -10,7 +10,7 @@ import { runImportStep } from './steps/import-run.js'; import { runPostImport } from './steps/post-import.js'; import { showSummary } from './steps/summary.js'; -export type Provider = 'auth0' | 'clerk' | 'firebase' | 'cognito' | 'csv'; +export type Provider = 'auth0' | 'clerk' | 'firebase' | 'cognito' | 'supabase' | 'csv'; export interface WizardState { // Step 1: Provider @@ -46,6 +46,22 @@ export interface WizardState { cognitoUserPoolIds?: string; cognitoEntities?: string; cognitoOutputDir?: string; + supabaseUrl?: string; + supabaseServiceRoleKey?: string; + supabaseDbUrl?: string; + supabasePackageDir?: string; + supabaseEntities?: string[]; + supabaseTotpIssuer?: string; + supabaseOrgTable?: string; + supabaseOrgIdColumn?: string; + supabaseOrgNameColumn?: string; + supabaseOrgExternalIdColumn?: string; + supabaseOrgDomainsColumn?: string; + supabaseMembersTable?: string; + supabaseMembershipUserColumn?: string; + supabaseMembershipOrgColumn?: string; + supabaseMembershipRoleColumn?: string; + supabaseRoleSlugMapPath?: string; customCsvPath?: string; // Step 4: Export output @@ -108,8 +124,11 @@ export class MigrationWizard { this.state = await runExport(this.state); if (this.state.cancelled) return this.onCancel(); - // Step 5: Password merge (Auth0 only) - if (this.state.provider === 'auth0') { + // Step 5: Password merge (Auth0 + Supabase-with-DB) + const needsPasswordMerge = + this.state.provider === 'auth0' || + (this.state.provider === 'supabase' && Boolean(this.state.supabaseDbUrl)); + if (needsPasswordMerge) { this.state = await mergePasswords(this.state); if (this.state.cancelled) return this.onCancel(); } From 52342c08b7a006ded4437c05fbf87ff108bd4211 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 19 May 2026 12:23:46 -0700 Subject: [PATCH 5/6] feat(supabase): move wizard credentials into credentials.ts Closes the one scope cut from Phase 4 (commit a904593), now that the credentials.ts permission block is resolved. - credentials.ts gets a `state.provider === 'supabase'` branch matching the Auth0/Cognito pattern: text URL prompt with the spec's strict `^https://.+\.supabase\.co/?$` regex, masked password prompt for the service-role key, confirm "have DB URL?" defaulted by SUPABASE_DB_URL presence, and a conditional masked DB URL prompt validating postgres://|postgresql:// prefix. Env vars (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_DB_URL) populate the prompt initials so existing scripted setups keep working. - export-config.ts loses its env-var-read preamble and missing-credentials cancel path (26 lines). configureSupabaseExport now starts with `const hasDb = Boolean(state.supabaseDbUrl)` and consumes credentials via state, mirroring Auth0/Cognito. - WORKOS_SECRET_KEY is intentionally not re-prompted inside the Supabase branch; the shared top-of-function prompt already handles it for every provider, matching the spec's "Key decisions" prose. Validation: typecheck, lint, 367/367 tests, build all clean. Reviewer verdict: PASS (cycle 1), 2 medium + 1 low non-blocking findings (style suggestions). --- src/wizard/steps/credentials.ts | 58 +++++++++++++++++++++++++++++++ src/wizard/steps/export-config.ts | 25 +------------ 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/src/wizard/steps/credentials.ts b/src/wizard/steps/credentials.ts index 9e607c3..6507613 100644 --- a/src/wizard/steps/credentials.ts +++ b/src/wizard/steps/credentials.ts @@ -111,5 +111,63 @@ export async function enterCredentials(state: WizardState): Promise ); } + if (state.provider === 'supabase') { + const envDbUrl = process.env.SUPABASE_DB_URL || ''; + + const supabaseCreds = await prompts( + [ + { + type: 'text', + name: 'url', + message: 'Supabase project URL (e.g. https://xxxx.supabase.co)', + initial: process.env.SUPABASE_URL || '', + validate: (v: string) => + /^https:\/\/.+\.supabase\.co\/?$/.test(v) || 'Expected https://xxxx.supabase.co', + }, + { + type: 'password', + name: 'serviceRoleKey', + message: 'Supabase Service Role Key (JWT)', + initial: process.env.SUPABASE_SERVICE_ROLE_KEY || '', + validate: (v: string) => v.length > 0 || 'Required', + }, + { + type: 'confirm', + name: 'hasDb', + message: 'Provide a Postgres connection string (enables passwords, MFA, SSO, organizations)?', + initial: Boolean(envDbUrl), + }, + { + type: (prev: boolean) => (prev ? 'password' : null), + name: 'dbUrl', + message: 'Postgres connection string (postgres://...)', + initial: envDbUrl, + validate: (v: string) => + v.startsWith('postgres://') || v.startsWith('postgresql://') || + 'Expected postgres:// or postgresql:// URL', + }, + ], + { + onCancel: () => { + state.cancelled = true; + }, + }, + ); + + if (state.cancelled) return state; + + state.supabaseUrl = supabaseCreds.url; + state.supabaseServiceRoleKey = supabaseCreds.serviceRoleKey; + state.supabaseDbUrl = supabaseCreds.hasDb ? supabaseCreds.dbUrl : undefined; + console.log(chalk.green(' Supabase credentials configured.\n')); + if (!state.supabaseDbUrl) { + console.log( + chalk.gray( + ' No DB URL provided — passwords, MFA, SSO, and organizations will be skipped.\n', + ), + ); + } + } + return state; } diff --git a/src/wizard/steps/export-config.ts b/src/wizard/steps/export-config.ts index 4751ad3..f5a6177 100644 --- a/src/wizard/steps/export-config.ts +++ b/src/wizard/steps/export-config.ts @@ -366,30 +366,7 @@ async function configureCustomCsv(state: WizardState): Promise { } async function configureSupabaseExport(state: WizardState): Promise { - console.log(chalk.gray(' Supabase credentials are read from environment variables:')); - console.log(chalk.gray(' SUPABASE_URL (required)')); - console.log(chalk.gray(' SUPABASE_SERVICE_ROLE_KEY (required)')); - console.log(chalk.gray(' SUPABASE_DB_URL (optional — needed for passwords/MFA/SSO/orgs)\n')); - - const url = process.env.SUPABASE_URL?.trim(); - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); - const dbUrl = process.env.SUPABASE_DB_URL?.trim(); - - if (!url || !serviceRoleKey) { - console.error( - chalk.red( - ' Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY. Set them and re-run the wizard.', - ), - ); - state.cancelled = true; - return state; - } - - state.supabaseUrl = url; - state.supabaseServiceRoleKey = serviceRoleKey; - state.supabaseDbUrl = dbUrl || undefined; - - const hasDb = Boolean(dbUrl); + const hasDb = Boolean(state.supabaseDbUrl); const response = await prompts( [ From 55e0dde5e3c9b8c05803bb0e00e94cff50ad1ea3 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Tue, 19 May 2026 12:35:51 -0700 Subject: [PATCH 6/6] style: apply prettier formatting CI prettier --check was failing on 19 files. Run prettier --write to bring them in line with the project's formatting config. --- README.md | 16 ++++---- src/cli/commands/export-supabase.ts | 5 ++- .../__tests__/admin-api-client.test.ts | 4 +- .../supabase/__tests__/exporter.test.ts | 12 ++++-- .../supabase/__tests__/mfa-mapper.test.ts | 14 ++++++- .../supabase/__tests__/org-mapper.test.ts | 7 +++- .../__tests__/password-merger.test.ts | 32 ++++++++++++--- .../supabase/__tests__/pg-client.test.ts | 23 +++++++---- .../supabase/__tests__/role-slug-map.test.ts | 4 +- .../supabase/__tests__/user-mapper.test.ts | 2 +- src/exporters/supabase/org-mapper.ts | 3 +- src/exporters/supabase/org-schema.ts | 5 ++- src/exporters/supabase/password-merger.ts | 9 +---- src/exporters/supabase/role-slug-map.ts | 13 ++++-- src/exporters/supabase/sso-mapper.ts | 13 +++--- src/exporters/supabase/user-mapper.ts | 8 ++-- src/wizard/steps/credentials.ts | 6 ++- src/wizard/steps/export-config.ts | 40 ++++++++++++------- src/wizard/steps/password-merge.ts | 4 +- 19 files changed, 150 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 433ddfd..b6d52cd 100644 --- a/README.md +++ b/README.md @@ -376,14 +376,14 @@ This produces a migration package directory with `users.csv`, `totp_secrets.csv` If you omit `--db-url`, the export still produces `users.csv` (with `metadata.supabase_identities`) but `mfa` and `sso` are skipped with warnings. -| Flag | Default | Description | -| -------------------------- | ------------ | ---------------------------------------------------------------------------------------- | -| `--rate-limit ` | 50 | Admin API requests per second | -| `--page-size ` | 1000 | Users per Admin API page | -| `--entities ` | `users` | Comma-separated entities — `users`, `identities`, `mfa`, `sso`, `organizations` | -| `--db-url ` | — | Postgres connection string (required for `mfa`/`sso`/`organizations`); also reads `SUPABASE_DB_URL` | -| `--totp-issuer ` | `Supabase` | Issuer label written into `totp_secrets.csv` | -| Org schema flags | — | See [docs/supabase-org-schema.md](./docs/supabase-org-schema.md) for the full schema-flag reference (`--org-table`, `--org-members-table`, `--membership-role-column`, `--role-slug-map`, etc.) | +| Flag | Default | Description | +| ------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--rate-limit ` | 50 | Admin API requests per second | +| `--page-size ` | 1000 | Users per Admin API page | +| `--entities ` | `users` | Comma-separated entities — `users`, `identities`, `mfa`, `sso`, `organizations` | +| `--db-url ` | — | Postgres connection string (required for `mfa`/`sso`/`organizations`); also reads `SUPABASE_DB_URL` | +| `--totp-issuer ` | `Supabase` | Issuer label written into `totp_secrets.csv` | +| Org schema flags | — | See [docs/supabase-org-schema.md](./docs/supabase-org-schema.md) for the full schema-flag reference (`--org-table`, `--org-members-table`, `--membership-role-column`, `--role-slug-map`, etc.) | ### 3. Merge bcrypt password hashes (optional) diff --git a/src/cli/commands/export-supabase.ts b/src/cli/commands/export-supabase.ts index 88fe66f..4c5d2ea 100644 --- a/src/cli/commands/export-supabase.ts +++ b/src/cli/commands/export-supabase.ts @@ -34,7 +34,10 @@ export function registerExportSupabaseCommand(program: Command): void { '--db-url ', 'Postgres connection string (required for mfa, sso, organizations; or set SUPABASE_DB_URL)', ) - .option('--totp-issuer ', 'Issuer label written into totp_secrets.csv (default: Supabase)') + .option( + '--totp-issuer ', + 'Issuer label written into totp_secrets.csv (default: Supabase)', + ) .option( '--org-table
', 'Postgres table holding organizations (e.g., public.organizations)', diff --git a/src/exporters/supabase/__tests__/admin-api-client.test.ts b/src/exporters/supabase/__tests__/admin-api-client.test.ts index 602a920..a8f3119 100644 --- a/src/exporters/supabase/__tests__/admin-api-client.test.ts +++ b/src/exporters/supabase/__tests__/admin-api-client.test.ts @@ -77,7 +77,9 @@ describe('SupabaseAdminClient', () => { it('dedupes users that appear in multiple pages and reports the first duplicate', async () => { const sharedId = '99999999-9999-9999-9999-999999999999'; fetchMock - .mockResolvedValueOnce(jsonResponse({ users: makeUsers([sharedId, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']) })) + .mockResolvedValueOnce( + jsonResponse({ users: makeUsers([sharedId, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa']) }), + ) .mockResolvedValueOnce(jsonResponse({ users: makeUsers([sharedId]) })) .mockResolvedValueOnce(jsonResponse({ users: [] })); diff --git a/src/exporters/supabase/__tests__/exporter.test.ts b/src/exporters/supabase/__tests__/exporter.test.ts index 315cb19..260464a 100644 --- a/src/exporters/supabase/__tests__/exporter.test.ts +++ b/src/exporters/supabase/__tests__/exporter.test.ts @@ -24,9 +24,15 @@ describe('exportSupabase (end-to-end)', () => { }); it('produces a complete package directory from two pages of users', async () => { - const page1 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8')); - const page2 = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-2.json'), 'utf-8')); - const empty = JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8')); + const page1 = JSON.parse( + fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-1.json'), 'utf-8'), + ); + const page2 = JSON.parse( + fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-2.json'), 'utf-8'), + ); + const empty = JSON.parse( + fs.readFileSync(path.join(FIXTURES_DIR, 'admin-users-page-3-empty.json'), 'utf-8'), + ); fetchMock // testConnection() makes a per_page=1 probe call first diff --git a/src/exporters/supabase/__tests__/mfa-mapper.test.ts b/src/exporters/supabase/__tests__/mfa-mapper.test.ts index 05e3e5a..845bd73 100644 --- a/src/exporters/supabase/__tests__/mfa-mapper.test.ts +++ b/src/exporters/supabase/__tests__/mfa-mapper.test.ts @@ -73,7 +73,12 @@ describe('exportMfaFactors', () => { it('uses the supplied totpIssuer when provided', async () => { const pg = fakeClientWithRows([ - { email: 'alice@example.com', factor_type: 'totp', secret: 'JBSWY3DPEHPK3PXP', status: 'verified' }, + { + email: 'alice@example.com', + factor_type: 'totp', + secret: 'JBSWY3DPEHPK3PXP', + status: 'verified', + }, ]); const result = await exportMfaFactors(pg, { totpIssuer: 'Acme' }); expect(result.records[0].totpIssuer).toBe('Acme'); @@ -90,7 +95,12 @@ describe('exportMfaFactors', () => { it('emits an extra warning when query returned rows but emitted nothing', async () => { const pg = fakeClientWithRows([ - { email: 'webauthn-only@example.com', factor_type: 'webauthn', secret: 'x', status: 'verified' }, + { + email: 'webauthn-only@example.com', + factor_type: 'webauthn', + secret: 'x', + status: 'verified', + }, ]); const result = await exportMfaFactors(pg); expect(result.warnings.some((w) => /emitted 0/.test(w))).toBe(true); diff --git a/src/exporters/supabase/__tests__/org-mapper.test.ts b/src/exporters/supabase/__tests__/org-mapper.test.ts index 9977226..af25b2b 100644 --- a/src/exporters/supabase/__tests__/org-mapper.test.ts +++ b/src/exporters/supabase/__tests__/org-mapper.test.ts @@ -134,7 +134,12 @@ describe('exportOrganizations', () => { const pg = fakeClient((sql) => { if (/AS org_internal_id/.test(sql)) { return [ - { org_internal_id: 'u1', org_name: 'Acme', org_external_id: 'acme', org_domains: 'acme.com' }, + { + org_internal_id: 'u1', + org_name: 'Acme', + org_external_id: 'acme', + org_domains: 'acme.com', + }, ]; } if (/u\.email AS email/.test(sql)) { diff --git a/src/exporters/supabase/__tests__/password-merger.test.ts b/src/exporters/supabase/__tests__/password-merger.test.ts index f49e6b4..979adbc 100644 --- a/src/exporters/supabase/__tests__/password-merger.test.ts +++ b/src/exporters/supabase/__tests__/password-merger.test.ts @@ -44,7 +44,11 @@ async function setupPackage( secretsRedacted: true, warnings: [], }; - await fsp.writeFile(path.join(rootDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8'); + await fsp.writeFile( + path.join(rootDir, 'manifest.json'), + JSON.stringify(manifest, null, 2), + 'utf-8', + ); if (options.withoutUsersCsv) return; @@ -103,7 +107,9 @@ describe('mergeSupabasePasswords', () => { expect(stats.unsupportedAlgo).toBe(0); const rows = await readUsersCsv(tmpDir); - expect(rows.find((r) => r.email === 'alice@example.com')?.password_hash).toBe('$2a$10$alicebcrypt'); + expect(rows.find((r) => r.email === 'alice@example.com')?.password_hash).toBe( + '$2a$10$alicebcrypt', + ); expect(rows.find((r) => r.email === 'alice@example.com')?.password_hash_type).toBe('bcrypt'); expect(rows.find((r) => r.email === 'bob@example.com')?.password_hash).toBe('$2b$12$bobbcrypt'); @@ -125,7 +131,10 @@ describe('mergeSupabasePasswords', () => { clientFactory: () => fakeClient([ { email: 'alice@example.com', encrypted_password: '$2y$10$alicebcrypt' }, - { email: 'argon@example.com', encrypted_password: '$argon2id$v=19$m=4096,t=3,p=1$xxxx$yyyy' }, + { + email: 'argon@example.com', + encrypted_password: '$argon2id$v=19$m=4096,t=3,p=1$xxxx$yyyy', + }, ]), }); @@ -136,12 +145,23 @@ describe('mergeSupabasePasswords', () => { it('is idempotent: running twice produces identical CSV', async () => { await setupPackage(tmpDir, [{ email: 'alice@example.com', external_id: 'u1' }]); - const factory = () => fakeClient([{ email: 'alice@example.com', encrypted_password: '$2a$10$alicebcrypt' }]); + const factory = () => + fakeClient([{ email: 'alice@example.com', encrypted_password: '$2a$10$alicebcrypt' }]); - await mergeSupabasePasswords({ packageDir: tmpDir, dbUrl: 'postgresql://x', quiet: true, clientFactory: factory }); + await mergeSupabasePasswords({ + packageDir: tmpDir, + dbUrl: 'postgresql://x', + quiet: true, + clientFactory: factory, + }); const first = await fsp.readFile(path.join(tmpDir, 'users.csv'), 'utf-8'); - await mergeSupabasePasswords({ packageDir: tmpDir, dbUrl: 'postgresql://x', quiet: true, clientFactory: factory }); + await mergeSupabasePasswords({ + packageDir: tmpDir, + dbUrl: 'postgresql://x', + quiet: true, + clientFactory: factory, + }); const second = await fsp.readFile(path.join(tmpDir, 'users.csv'), 'utf-8'); expect(second).toBe(first); }); diff --git a/src/exporters/supabase/__tests__/pg-client.test.ts b/src/exporters/supabase/__tests__/pg-client.test.ts index 576ed6e..6badcdb 100644 --- a/src/exporters/supabase/__tests__/pg-client.test.ts +++ b/src/exporters/supabase/__tests__/pg-client.test.ts @@ -9,7 +9,9 @@ interface FakePool extends PgPoolLike { closed: boolean; } -function createFakePoolFactory(queryImpl?: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>): { +function createFakePoolFactory( + queryImpl?: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>, +): { factory: (config: PoolConfig) => PgPoolLike; pools: FakePool[]; } { @@ -50,7 +52,9 @@ describe('SupabasePgClient', () => { expect(pools).toHaveLength(1); expect(pools[0].config.max).toBe(1); expect(pools[0].config.statement_timeout).toBe(12_000); - expect(pools[0].config.connectionString).toBe('postgresql://user:pw@db.example.com:5432/postgres'); + expect(pools[0].config.connectionString).toBe( + 'postgresql://user:pw@db.example.com:5432/postgres', + ); }); it('uses a 30s default statement timeout when not specified', () => { @@ -100,16 +104,21 @@ describe('SupabasePgClient', () => { }); it('forwards query() params to the pool and returns rows', async () => { - const { factory, pools } = createFakePoolFactory(async (_sql, params) => ({ - rows: [{ email: 'a@example.com', encrypted_password: '$2b$10$abc' }], - lastParams: params, - } as unknown as { rows: unknown[] })); + const { factory, pools } = createFakePoolFactory( + async (_sql, params) => + ({ + rows: [{ email: 'a@example.com', encrypted_password: '$2b$10$abc' }], + lastParams: params, + }) as unknown as { rows: unknown[] }, + ); const client = new SupabasePgClient({ connectionString: 'postgresql://user:pw@db.example.com:5432/postgres', poolFactory: factory, }); - const rows = await client.query<{ email: string }>('SELECT 1 WHERE x = ANY($1)', [['a@example.com']]); + const rows = await client.query<{ email: string }>('SELECT 1 WHERE x = ANY($1)', [ + ['a@example.com'], + ]); expect(rows).toHaveLength(1); expect(rows[0].email).toBe('a@example.com'); expect(pools[0].queries[0].params).toEqual([['a@example.com']]); diff --git a/src/exporters/supabase/__tests__/role-slug-map.test.ts b/src/exporters/supabase/__tests__/role-slug-map.test.ts index 8bd76ea..02ef862 100644 --- a/src/exporters/supabase/__tests__/role-slug-map.test.ts +++ b/src/exporters/supabase/__tests__/role-slug-map.test.ts @@ -35,7 +35,9 @@ describe('loadRoleSlugMap', () => { }); it('rejects unsupported extensions', async () => { - await expect(loadRoleSlugMap(path.join(tmpDir, 'roles.yaml'))).rejects.toThrow(/Unsupported.*\.yaml/); + await expect(loadRoleSlugMap(path.join(tmpDir, 'roles.yaml'))).rejects.toThrow( + /Unsupported.*\.yaml/, + ); }); it('throws when the file does not exist (JSON branch)', async () => { diff --git a/src/exporters/supabase/__tests__/user-mapper.test.ts b/src/exporters/supabase/__tests__/user-mapper.test.ts index 98fe2ba..fe6649b 100644 --- a/src/exporters/supabase/__tests__/user-mapper.test.ts +++ b/src/exporters/supabase/__tests__/user-mapper.test.ts @@ -59,7 +59,7 @@ describe('mapSupabaseUser', () => { expect(result.csvRow.last_name).toBe('Junior Builder'); }); - it("falls back to user_metadata.name when full_name is missing", () => { + it('falls back to user_metadata.name when full_name is missing', () => { const result = mapSupabaseUser(baseUser({ user_metadata: { name: 'Charlie Chaplin' } })); expect(result.csvRow.first_name).toBe('Charlie'); expect(result.csvRow.last_name).toBe('Chaplin'); diff --git a/src/exporters/supabase/org-mapper.ts b/src/exporters/supabase/org-mapper.ts index 9315b78..a2a8a58 100644 --- a/src/exporters/supabase/org-mapper.ts +++ b/src/exporters/supabase/org-mapper.ts @@ -36,7 +36,8 @@ export interface OrgExportResult { } export function mapOrgRow(row: OrgQueryRow): { csvRow: OrgCsvRow; warning?: string } { - const externalId = row.org_external_id?.toString().trim() || row.org_internal_id?.toString().trim() || ''; + const externalId = + row.org_external_id?.toString().trim() || row.org_internal_id?.toString().trim() || ''; const { domains, warning } = normalizeDomains(row.org_domains, externalId); const csvRow: OrgCsvRow = { org_id: '', diff --git a/src/exporters/supabase/org-schema.ts b/src/exporters/supabase/org-schema.ts index bcf086a..1b0fb2c 100644 --- a/src/exporters/supabase/org-schema.ts +++ b/src/exporters/supabase/org-schema.ts @@ -69,7 +69,10 @@ export function validateOrgSchemaFlags(flags: OrgSchemaFlags): OrgSchemaConfig | }; if (flags.orgExternalIdColumn) { - config.orgExternalIdColumn = validateColumn(flags.orgExternalIdColumn, '--org-external-id-column'); + config.orgExternalIdColumn = validateColumn( + flags.orgExternalIdColumn, + '--org-external-id-column', + ); } if (flags.orgDomainsColumn) { config.orgDomainsColumn = validateColumn(flags.orgDomainsColumn, '--org-domains-column'); diff --git a/src/exporters/supabase/password-merger.ts b/src/exporters/supabase/password-merger.ts index 1dba4da..0329515 100644 --- a/src/exporters/supabase/password-merger.ts +++ b/src/exporters/supabase/password-merger.ts @@ -145,9 +145,7 @@ export async function mergeSupabasePasswords( async function assertSupabasePackage(manifestPath: string): Promise { if (!(await pathExists(manifestPath))) { - throw new Error( - `Manifest not found at ${manifestPath}. Run export-supabase --package first.`, - ); + throw new Error(`Manifest not found at ${manifestPath}. Run export-supabase --package first.`); } const raw = await fsp.readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(raw) as MigrationPackageManifest; @@ -220,10 +218,7 @@ async function writeCsvAtomic( await fsp.rename(tmpPath, filePath); } -async function updateManifest( - manifestPath: string, - stats: PasswordMergeStats, -): Promise { +async function updateManifest(manifestPath: string, stats: PasswordMergeStats): Promise { const raw = await fsp.readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(raw) as MigrationPackageManifest; manifest.metadata = { diff --git a/src/exporters/supabase/role-slug-map.ts b/src/exporters/supabase/role-slug-map.ts index 40456c3..8e0a1e3 100644 --- a/src/exporters/supabase/role-slug-map.ts +++ b/src/exporters/supabase/role-slug-map.ts @@ -20,7 +20,9 @@ export async function loadRoleSlugMap(filePath: string): Promise { if (ext === '.json') return loadJson(resolved); if (ext === '.csv') return loadCsv(resolved); - throw new Error(`Unsupported role-slug-map extension: ${ext || ''} (expected .json or .csv)`); + throw new Error( + `Unsupported role-slug-map extension: ${ext || ''} (expected .json or .csv)`, + ); } export function applyRoleSlugMap( @@ -45,9 +47,12 @@ async function loadJson(filePath: string): Promise { try { parsed = JSON.parse(raw); } catch (error: unknown) { - throw new Error(`Failed to parse role-slug-map JSON at ${filePath}: ${(error as Error).message}`, { - cause: error, - }); + throw new Error( + `Failed to parse role-slug-map JSON at ${filePath}: ${(error as Error).message}`, + { + cause: error, + }, + ); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Role-slug-map JSON must be an object dict: ${filePath}`); diff --git a/src/exporters/supabase/sso-mapper.ts b/src/exporters/supabase/sso-mapper.ts index 2d57191..d988d8d 100644 --- a/src/exporters/supabase/sso-mapper.ts +++ b/src/exporters/supabase/sso-mapper.ts @@ -51,10 +51,7 @@ export async function exportSamlProviders(pg: SupabasePgQueryClient): Promise { +async function buildRow(provider: SupabaseSamlProviderRow, warnings: string[]): Promise { let metadataXml = provider.metadata_xml?.trim() ?? ''; if (!metadataXml && provider.metadata_url) { @@ -117,7 +114,13 @@ function readAttributeMapping(map: Record): SamlAttributeNames return { email: pickAttribute(keys, ['email', 'emailaddress', 'email_address']), firstName: pickAttribute(keys, ['first_name', 'firstname', 'given_name', 'givenname']), - lastName: pickAttribute(keys, ['last_name', 'lastname', 'family_name', 'familyname', 'surname']), + lastName: pickAttribute(keys, [ + 'last_name', + 'lastname', + 'family_name', + 'familyname', + 'surname', + ]), }; } diff --git a/src/exporters/supabase/user-mapper.ts b/src/exporters/supabase/user-mapper.ts index eaa9b15..e37009a 100644 --- a/src/exporters/supabase/user-mapper.ts +++ b/src/exporters/supabase/user-mapper.ts @@ -9,7 +9,10 @@ export interface MappedSupabaseUser { warnings: string[]; } -function readStringField(source: Record | undefined, key: string): string | undefined { +function readStringField( + source: Record | undefined, + key: string, +): string | undefined { const value = source?.[key]; return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; } @@ -97,8 +100,7 @@ export function mapSupabaseUser(user: SupabaseAdminUser): MappedSupabaseUser { } const { firstName, lastName } = resolveNames(user); - const emailVerified = - user.email_confirmed_at !== null && user.email_confirmed_at !== undefined; + const emailVerified = user.email_confirmed_at !== null && user.email_confirmed_at !== undefined; const metadata = buildMetadata(user); diff --git a/src/wizard/steps/credentials.ts b/src/wizard/steps/credentials.ts index 6507613..f43d5c4 100644 --- a/src/wizard/steps/credentials.ts +++ b/src/wizard/steps/credentials.ts @@ -134,7 +134,8 @@ export async function enterCredentials(state: WizardState): Promise { type: 'confirm', name: 'hasDb', - message: 'Provide a Postgres connection string (enables passwords, MFA, SSO, organizations)?', + message: + 'Provide a Postgres connection string (enables passwords, MFA, SSO, organizations)?', initial: Boolean(envDbUrl), }, { @@ -143,7 +144,8 @@ export async function enterCredentials(state: WizardState): Promise message: 'Postgres connection string (postgres://...)', initial: envDbUrl, validate: (v: string) => - v.startsWith('postgres://') || v.startsWith('postgresql://') || + v.startsWith('postgres://') || + v.startsWith('postgresql://') || 'Expected postgres:// or postgresql:// URL', }, ], diff --git a/src/wizard/steps/export-config.ts b/src/wizard/steps/export-config.ts index f5a6177..25ebd8a 100644 --- a/src/wizard/steps/export-config.ts +++ b/src/wizard/steps/export-config.ts @@ -376,10 +376,22 @@ async function configureSupabaseExport(state: WizardState): Promise message: 'Which entities should be exported?', choices: [ { title: 'Users', value: 'users', selected: true }, - { title: 'OAuth identities (stored in user metadata)', value: 'identities', selected: true }, + { + title: 'OAuth identities (stored in user metadata)', + value: 'identities', + selected: true, + }, { title: 'TOTP MFA factors (requires SUPABASE_DB_URL)', value: 'mfa', selected: hasDb }, - { title: 'SAML SSO connections (requires SUPABASE_DB_URL)', value: 'sso', selected: hasDb }, - { title: 'Organizations + memberships (requires SUPABASE_DB_URL + schema flags)', value: 'organizations', selected: false }, + { + title: 'SAML SSO connections (requires SUPABASE_DB_URL)', + value: 'sso', + selected: hasDb, + }, + { + title: 'Organizations + memberships (requires SUPABASE_DB_URL + schema flags)', + value: 'organizations', + selected: false, + }, ], min: 1, }, @@ -391,7 +403,9 @@ async function configureSupabaseExport(state: WizardState): Promise }, { type: (_: unknown, values: Record) => - Array.isArray(values.entities) && (values.entities as string[]).includes('mfa') ? 'text' : null, + Array.isArray(values.entities) && (values.entities as string[]).includes('mfa') + ? 'text' + : null, name: 'totpIssuer', message: 'TOTP issuer label (shown in authenticator apps)', initial: 'Supabase', @@ -406,36 +420,31 @@ async function configureSupabaseExport(state: WizardState): Promise validate: (v: string) => v.length > 0 || 'Required', }, { - type: (_: unknown, values: Record) => - values.orgTable ? 'text' : null, + type: (_: unknown, values: Record) => (values.orgTable ? 'text' : null), name: 'orgIdColumn', message: 'Org id column', initial: 'id', }, { - type: (_: unknown, values: Record) => - values.orgTable ? 'text' : null, + type: (_: unknown, values: Record) => (values.orgTable ? 'text' : null), name: 'orgNameColumn', message: 'Org name column', initial: 'name', }, { - type: (_: unknown, values: Record) => - values.orgTable ? 'text' : null, + type: (_: unknown, values: Record) => (values.orgTable ? 'text' : null), name: 'orgExternalIdColumn', message: 'Org external_id column (leave blank to use the id column)', initial: '', }, { - type: (_: unknown, values: Record) => - values.orgTable ? 'text' : null, + type: (_: unknown, values: Record) => (values.orgTable ? 'text' : null), name: 'orgDomainsColumn', message: 'Org domains column (leave blank if not stored)', initial: '', }, { - type: (_: unknown, values: Record) => - values.orgTable ? 'text' : null, + type: (_: unknown, values: Record) => (values.orgTable ? 'text' : null), name: 'membersTable', message: 'Members table (e.g., public.org_members)', validate: (v: string) => v.length > 0 || 'Required', @@ -465,7 +474,8 @@ async function configureSupabaseExport(state: WizardState): Promise type: (_: unknown, values: Record) => values.membershipRoleColumn ? 'text' : null, name: 'roleSlugMapPath', - message: 'Path to --role-slug-map JSON or CSV (leave blank to pass DB roles through verbatim)', + message: + 'Path to --role-slug-map JSON or CSV (leave blank to pass DB roles through verbatim)', initial: '', validate: (v: string) => !v || fs.existsSync(v) || 'File not found', }, diff --git a/src/wizard/steps/password-merge.ts b/src/wizard/steps/password-merge.ts index 1b161d5..d01141b 100644 --- a/src/wizard/steps/password-merge.ts +++ b/src/wizard/steps/password-merge.ts @@ -117,7 +117,9 @@ async function mergeSupabasePasswordsStep(state: WizardState): Promise