@atlasauth/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/args.d.ts +39 -0
- package/dist/args.js +15 -0
- package/dist/args.js.map +1 -1
- package/dist/commands/import.d.ts +3 -0
- package/dist/commands/import.js +116 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/mcp.d.ts +38 -0
- package/dist/commands/mcp.js +108 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/referrals.d.ts +18 -4
- package/dist/commands/referrals.js +161 -26
- package/dist/commands/referrals.js.map +1 -1
- package/dist/help.d.ts +1 -1
- package/dist/help.js +57 -5
- package/dist/help.js.map +1 -1
- package/dist/import/csv.d.ts +6 -0
- package/dist/import/csv.js +63 -0
- package/dist/import/csv.js.map +1 -0
- package/dist/import/vendors.d.ts +57 -0
- package/dist/import/vendors.js +568 -0
- package/dist/import/vendors.js.map +1 -0
- package/dist/run.js +4 -0
- package/dist/run.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor export → Atlas bulk-import rows. Each mapper reads the file format the
|
|
3
|
+
* vendor's own export produces and returns the rows `POST /v1/user_imports`
|
|
4
|
+
* accepts, plus warnings for what could not be carried over. Pure functions of
|
|
5
|
+
* the parsed file, so every mapping is tested from a fixture with no network.
|
|
6
|
+
*/
|
|
7
|
+
export declare const VENDORS: readonly ["auth0", "clerk", "supabase", "firebase", "cognito", "authjs", "workos", "better-auth"];
|
|
8
|
+
export type Vendor = (typeof VENDORS)[number];
|
|
9
|
+
export interface ImportMembershipRow {
|
|
10
|
+
organization_id?: string;
|
|
11
|
+
organization_external_id?: string;
|
|
12
|
+
organization_slug?: string;
|
|
13
|
+
organization_name?: string;
|
|
14
|
+
role?: string;
|
|
15
|
+
}
|
|
16
|
+
/** One row of the Atlas import body. Only fields with a value are set. */
|
|
17
|
+
export interface ImportRow {
|
|
18
|
+
email_address: string;
|
|
19
|
+
email_verified?: boolean;
|
|
20
|
+
first_name?: string;
|
|
21
|
+
last_name?: string;
|
|
22
|
+
username?: string;
|
|
23
|
+
external_id?: string;
|
|
24
|
+
created_at?: string | number;
|
|
25
|
+
password_hash?: string;
|
|
26
|
+
password_salt?: string;
|
|
27
|
+
password_hasher?: string;
|
|
28
|
+
phone_numbers?: Array<{
|
|
29
|
+
phone_number: string;
|
|
30
|
+
verified?: boolean;
|
|
31
|
+
}>;
|
|
32
|
+
external_accounts?: Array<{
|
|
33
|
+
provider: string;
|
|
34
|
+
provider_user_id: string;
|
|
35
|
+
email?: string;
|
|
36
|
+
}>;
|
|
37
|
+
organization_memberships?: ImportMembershipRow[];
|
|
38
|
+
public_metadata?: Record<string, unknown>;
|
|
39
|
+
private_metadata?: Record<string, unknown>;
|
|
40
|
+
unsafe_metadata?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
export interface MappedExport {
|
|
43
|
+
rows: ImportRow[];
|
|
44
|
+
/** Human-readable notes: records skipped, data that cannot move, follow-ups. */
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
/** Parse the file text: a JSON document, newline-delimited JSON, or CSV. */
|
|
48
|
+
export declare function parseExport(text: string, fileName?: string): unknown;
|
|
49
|
+
export declare function atlasProvider(raw: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Map a parsed vendor export. `hashes` is an optional second file of password
|
|
52
|
+
* hashes keyed by email (Auth0's support export, WorkOS's, or any `{email,
|
|
53
|
+
* password_hash}` list), merged onto the rows.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mapExport(vendor: Vendor, doc: unknown, hashes?: unknown): MappedExport;
|
|
56
|
+
/** A one-screen summary of what an import would do (the dry run). */
|
|
57
|
+
export declare function summarize(rows: ImportRow[]): Record<string, number>;
|
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VENDORS = void 0;
|
|
4
|
+
exports.parseExport = parseExport;
|
|
5
|
+
exports.atlasProvider = atlasProvider;
|
|
6
|
+
exports.mapExport = mapExport;
|
|
7
|
+
exports.summarize = summarize;
|
|
8
|
+
const csv_1 = require("./csv");
|
|
9
|
+
/**
|
|
10
|
+
* Vendor export → Atlas bulk-import rows. Each mapper reads the file format the
|
|
11
|
+
* vendor's own export produces and returns the rows `POST /v1/user_imports`
|
|
12
|
+
* accepts, plus warnings for what could not be carried over. Pure functions of
|
|
13
|
+
* the parsed file, so every mapping is tested from a fixture with no network.
|
|
14
|
+
*/
|
|
15
|
+
exports.VENDORS = [
|
|
16
|
+
'auth0',
|
|
17
|
+
'clerk',
|
|
18
|
+
'supabase',
|
|
19
|
+
'firebase',
|
|
20
|
+
'cognito',
|
|
21
|
+
'authjs',
|
|
22
|
+
'workos',
|
|
23
|
+
'better-auth',
|
|
24
|
+
];
|
|
25
|
+
/** Parse the file text: a JSON document, newline-delimited JSON, or CSV. */
|
|
26
|
+
function parseExport(text, fileName = '') {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (fileName.toLowerCase().endsWith('.csv'))
|
|
29
|
+
return (0, csv_1.parseCsv)(text);
|
|
30
|
+
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(trimmed);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Not one JSON document: maybe one object per line (Auth0's export job).
|
|
36
|
+
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim());
|
|
37
|
+
if (lines.every((l) => l.trim().startsWith('{')))
|
|
38
|
+
return lines.map((l) => JSON.parse(l));
|
|
39
|
+
throw new Error('The file is not valid JSON, NDJSON or CSV.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return (0, csv_1.parseCsv)(text);
|
|
43
|
+
}
|
|
44
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
45
|
+
const s = (v) => typeof v === 'string' && v.trim() ? v.trim() : typeof v === 'number' ? String(v) : undefined;
|
|
46
|
+
const truthy = (v) => v === true || v === 1 || (typeof v === 'string' && ['true', '1', 'yes', 't'].includes(v.trim().toLowerCase()));
|
|
47
|
+
const json = (v) => {
|
|
48
|
+
if (isObj(v))
|
|
49
|
+
return Object.keys(v).length ? v : undefined;
|
|
50
|
+
if (typeof v === 'string' && v.trim().startsWith('{')) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(v);
|
|
53
|
+
return isObj(parsed) && Object.keys(parsed).length ? parsed : undefined;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
};
|
|
61
|
+
/** A list from either an array field or a top-level wrapper key. */
|
|
62
|
+
const listOf = (doc, ...keys) => {
|
|
63
|
+
if (Array.isArray(doc))
|
|
64
|
+
return doc.filter(isObj);
|
|
65
|
+
if (isObj(doc)) {
|
|
66
|
+
for (const k of keys) {
|
|
67
|
+
const v = doc[k];
|
|
68
|
+
if (Array.isArray(v))
|
|
69
|
+
return v.filter(isObj);
|
|
70
|
+
}
|
|
71
|
+
// A one-line NDJSON export parses as a single record.
|
|
72
|
+
if (keys.length && typeof doc.email === 'string')
|
|
73
|
+
return [doc];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
};
|
|
77
|
+
function splitName(name) {
|
|
78
|
+
if (!name)
|
|
79
|
+
return {};
|
|
80
|
+
const [first, ...rest] = name.trim().split(/\s+/);
|
|
81
|
+
return { first_name: first, ...(rest.length ? { last_name: rest.join(' ') } : {}) };
|
|
82
|
+
}
|
|
83
|
+
/** Drop undefined fields so the request body only carries what the export had. */
|
|
84
|
+
function clean(row) {
|
|
85
|
+
for (const k of Object.keys(row)) {
|
|
86
|
+
const v = row[k];
|
|
87
|
+
if (v === undefined || (Array.isArray(v) && v.length === 0))
|
|
88
|
+
delete row[k];
|
|
89
|
+
}
|
|
90
|
+
return row;
|
|
91
|
+
}
|
|
92
|
+
/** Social provider names used by vendors → Atlas provider keys. */
|
|
93
|
+
const PROVIDER_ALIASES = {
|
|
94
|
+
'google-oauth2': 'google',
|
|
95
|
+
'google.com': 'google',
|
|
96
|
+
oauth_google: 'google',
|
|
97
|
+
'facebook.com': 'facebook',
|
|
98
|
+
oauth_facebook: 'facebook',
|
|
99
|
+
'github.com': 'github',
|
|
100
|
+
oauth_github: 'github',
|
|
101
|
+
'apple.com': 'apple',
|
|
102
|
+
oauth_apple: 'apple',
|
|
103
|
+
'twitter.com': 'twitter',
|
|
104
|
+
oauth_twitter: 'twitter',
|
|
105
|
+
'microsoft.com': 'microsoft',
|
|
106
|
+
windowslive: 'microsoft',
|
|
107
|
+
oauth_microsoft: 'microsoft',
|
|
108
|
+
'linkedin-openid': 'linkedin',
|
|
109
|
+
oauth_linkedin_oidc: 'linkedin',
|
|
110
|
+
oauth_linkedin: 'linkedin',
|
|
111
|
+
oauth_discord: 'discord',
|
|
112
|
+
oauth_gitlab: 'gitlab',
|
|
113
|
+
oauth_slack: 'slack',
|
|
114
|
+
azure: 'microsoft',
|
|
115
|
+
GoogleOAuth: 'google',
|
|
116
|
+
MicrosoftOAuth: 'microsoft',
|
|
117
|
+
GitHubOAuth: 'github',
|
|
118
|
+
AppleOAuth: 'apple',
|
|
119
|
+
};
|
|
120
|
+
function atlasProvider(raw) {
|
|
121
|
+
return PROVIDER_ALIASES[raw] ?? PROVIDER_ALIASES[raw.toLowerCase()] ?? raw.replace(/^oauth_/, '').toLowerCase();
|
|
122
|
+
}
|
|
123
|
+
// ── per-vendor mappers ──────────────────────────────────────────────────────
|
|
124
|
+
/** Auth0: the user export job (NDJSON/JSON); bcrypt hashes from the password export are merged by email. */
|
|
125
|
+
function mapAuth0(doc) {
|
|
126
|
+
const warnings = [];
|
|
127
|
+
const records = listOf(doc, 'users');
|
|
128
|
+
const rows = [];
|
|
129
|
+
let skipped = 0;
|
|
130
|
+
for (const r of records) {
|
|
131
|
+
const email = s(r.email);
|
|
132
|
+
if (!email) {
|
|
133
|
+
skipped++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const identities = Array.isArray(r.identities) ? r.identities.filter(isObj) : [];
|
|
137
|
+
const social = identities.filter((i) => truthy(i.isSocial) && s(i.provider) && s(i.user_id));
|
|
138
|
+
// The password export (via Auth0 support) carries `passwordHash`; merged in when present.
|
|
139
|
+
const hash = s(r.passwordHash) ?? s(r.password_hash);
|
|
140
|
+
rows.push(clean({
|
|
141
|
+
email_address: email,
|
|
142
|
+
email_verified: truthy(r.email_verified),
|
|
143
|
+
first_name: s(r.given_name) ?? splitName(s(r.name)).first_name,
|
|
144
|
+
last_name: s(r.family_name) ?? (s(r.given_name) ? undefined : splitName(s(r.name)).last_name),
|
|
145
|
+
username: s(r.username),
|
|
146
|
+
external_id: s(r.user_id),
|
|
147
|
+
created_at: s(r.created_at),
|
|
148
|
+
password_hash: hash,
|
|
149
|
+
phone_numbers: s(r.phone_number) ? [{ phone_number: s(r.phone_number), verified: truthy(r.phone_verified) }] : [],
|
|
150
|
+
external_accounts: social.map((i) => ({ provider: atlasProvider(s(i.provider)), provider_user_id: s(i.user_id) })),
|
|
151
|
+
public_metadata: json(r.app_metadata),
|
|
152
|
+
unsafe_metadata: json(r.user_metadata),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
if (skipped)
|
|
156
|
+
warnings.push(`${skipped} record(s) without an email address were skipped (phone-only or username-only users).`);
|
|
157
|
+
if (!rows.some((r) => r.password_hash)) {
|
|
158
|
+
warnings.push('No password hashes found. Request the password hash export from Auth0 support and merge it with --hashes, or enable lazy migration so passwords move on first sign-in.');
|
|
159
|
+
}
|
|
160
|
+
return { rows, warnings };
|
|
161
|
+
}
|
|
162
|
+
/** Clerk: the dashboard CSV export, or users JSON from the Backend API. */
|
|
163
|
+
function mapClerk(doc) {
|
|
164
|
+
const warnings = [];
|
|
165
|
+
const rows = [];
|
|
166
|
+
let skipped = 0;
|
|
167
|
+
for (const r of listOf(doc, 'data', 'users')) {
|
|
168
|
+
// JSON (API) shape.
|
|
169
|
+
if (Array.isArray(r.email_addresses)) {
|
|
170
|
+
const emails = r.email_addresses.filter(isObj);
|
|
171
|
+
const primary = emails.find((e) => e.id === r.primary_email_address_id) ?? emails[0];
|
|
172
|
+
const email = s(primary?.email_address);
|
|
173
|
+
if (!email) {
|
|
174
|
+
skipped++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const verified = isObj(primary?.verification) && primary.verification.status === 'verified';
|
|
178
|
+
const phones = (Array.isArray(r.phone_numbers) ? r.phone_numbers.filter(isObj) : [])
|
|
179
|
+
.filter((p) => s(p.phone_number))
|
|
180
|
+
.map((p) => ({
|
|
181
|
+
phone_number: s(p.phone_number),
|
|
182
|
+
verified: isObj(p.verification) && p.verification.status === 'verified',
|
|
183
|
+
}));
|
|
184
|
+
const ext = (Array.isArray(r.external_accounts) ? r.external_accounts.filter(isObj) : [])
|
|
185
|
+
.filter((e) => s(e.provider) && s(e.provider_user_id))
|
|
186
|
+
.map((e) => ({ provider: atlasProvider(s(e.provider)), provider_user_id: s(e.provider_user_id), email: s(e.email_address) }));
|
|
187
|
+
const memberships = (Array.isArray(r.organization_memberships) ? r.organization_memberships.filter(isObj) : []).map((m) => {
|
|
188
|
+
const org = isObj(m.organization) ? m.organization : {};
|
|
189
|
+
return {
|
|
190
|
+
organization_external_id: s(org.id) ?? s(m.organization_id),
|
|
191
|
+
organization_slug: s(org.slug),
|
|
192
|
+
organization_name: s(org.name),
|
|
193
|
+
role: s(m.role)?.replace(/^org:/, ''),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
rows.push(clean({
|
|
197
|
+
email_address: email,
|
|
198
|
+
email_verified: verified,
|
|
199
|
+
first_name: s(r.first_name),
|
|
200
|
+
last_name: s(r.last_name),
|
|
201
|
+
username: s(r.username),
|
|
202
|
+
external_id: s(r.id),
|
|
203
|
+
created_at: typeof r.created_at === 'number' ? r.created_at : s(r.created_at),
|
|
204
|
+
password_hash: s(r.password_digest),
|
|
205
|
+
password_hasher: s(r.password_hasher),
|
|
206
|
+
phone_numbers: phones,
|
|
207
|
+
external_accounts: ext,
|
|
208
|
+
organization_memberships: memberships,
|
|
209
|
+
public_metadata: json(r.public_metadata),
|
|
210
|
+
private_metadata: json(r.private_metadata),
|
|
211
|
+
unsafe_metadata: json(r.unsafe_metadata),
|
|
212
|
+
}));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// CSV (dashboard export) shape.
|
|
216
|
+
const verifiedEmails = (s(r.verified_email_addresses) ?? '').split(/[|,]/).map((e) => e.trim()).filter(Boolean);
|
|
217
|
+
const email = s(r.primary_email_address) ?? verifiedEmails[0] ?? s(r.unverified_email_addresses)?.split(/[|,]/)[0];
|
|
218
|
+
if (!email) {
|
|
219
|
+
skipped++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const verifiedPhones = (s(r.verified_phone_numbers) ?? '').split(/[|,]/).map((p) => p.trim()).filter(Boolean);
|
|
223
|
+
const phone = s(r.primary_phone_number);
|
|
224
|
+
rows.push(clean({
|
|
225
|
+
email_address: email,
|
|
226
|
+
email_verified: verifiedEmails.includes(email),
|
|
227
|
+
first_name: s(r.first_name),
|
|
228
|
+
last_name: s(r.last_name),
|
|
229
|
+
username: s(r.username),
|
|
230
|
+
external_id: s(r.id),
|
|
231
|
+
password_hash: s(r.password_digest),
|
|
232
|
+
password_hasher: s(r.password_hasher),
|
|
233
|
+
phone_numbers: phone ? [{ phone_number: phone, verified: verifiedPhones.includes(phone) }] : [],
|
|
234
|
+
}));
|
|
235
|
+
if (s(r.totp_secret))
|
|
236
|
+
warnings.push(`User ${s(r.id) ?? email} has a TOTP secret; it is imported only through the API's mfa field, so they will re-enrol.`);
|
|
237
|
+
}
|
|
238
|
+
if (skipped)
|
|
239
|
+
warnings.push(`${skipped} record(s) without an email address were skipped.`);
|
|
240
|
+
return { rows, warnings };
|
|
241
|
+
}
|
|
242
|
+
/** Supabase: rows of auth.users (CSV or JSON), optionally with nested `identities`. */
|
|
243
|
+
function mapSupabase(doc) {
|
|
244
|
+
const warnings = [];
|
|
245
|
+
const rows = [];
|
|
246
|
+
let skipped = 0;
|
|
247
|
+
for (const r of listOf(doc, 'users')) {
|
|
248
|
+
const email = s(r.email);
|
|
249
|
+
if (!email) {
|
|
250
|
+
skipped++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const meta = json(r.raw_user_meta_data) ?? json(r.user_metadata);
|
|
254
|
+
const appMeta = json(r.raw_app_meta_data) ?? json(r.app_metadata);
|
|
255
|
+
const identities = Array.isArray(r.identities) ? r.identities.filter(isObj) : [];
|
|
256
|
+
const name = s(meta?.full_name) ?? s(meta?.name);
|
|
257
|
+
rows.push(clean({
|
|
258
|
+
email_address: email,
|
|
259
|
+
email_verified: Boolean(s(r.email_confirmed_at) ?? s(r.confirmed_at)),
|
|
260
|
+
...splitName(name),
|
|
261
|
+
external_id: s(r.id),
|
|
262
|
+
created_at: s(r.created_at),
|
|
263
|
+
password_hash: s(r.encrypted_password),
|
|
264
|
+
phone_numbers: s(r.phone) ? [{ phone_number: s(r.phone).startsWith('+') ? s(r.phone) : `+${s(r.phone)}`, verified: Boolean(s(r.phone_confirmed_at)) }] : [],
|
|
265
|
+
external_accounts: identities
|
|
266
|
+
.filter((i) => s(i.provider) && s(i.provider) !== 'email' && s(i.provider) !== 'phone' && (s(i.provider_id) ?? s(i.id)))
|
|
267
|
+
.map((i) => ({ provider: atlasProvider(s(i.provider)), provider_user_id: (s(i.provider_id) ?? s(i.id)) })),
|
|
268
|
+
// Supabase's own bookkeeping keys (`provider`, `providers`) stay behind.
|
|
269
|
+
public_metadata: json(appMeta ? Object.fromEntries(Object.entries(appMeta).filter(([k]) => k !== 'provider' && k !== 'providers')) : undefined),
|
|
270
|
+
unsafe_metadata: meta,
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
if (skipped)
|
|
274
|
+
warnings.push(`${skipped} record(s) without an email address were skipped.`);
|
|
275
|
+
return { rows, warnings };
|
|
276
|
+
}
|
|
277
|
+
/** Firebase: `firebase auth:export users.json --format=json`. Hash parameters come from the console. */
|
|
278
|
+
function mapFirebase(doc) {
|
|
279
|
+
const warnings = [];
|
|
280
|
+
const rows = [];
|
|
281
|
+
let skipped = 0;
|
|
282
|
+
let disabled = 0;
|
|
283
|
+
for (const r of listOf(doc, 'users')) {
|
|
284
|
+
const email = s(r.email);
|
|
285
|
+
if (!email) {
|
|
286
|
+
skipped++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (truthy(r.disabled))
|
|
290
|
+
disabled++;
|
|
291
|
+
const providers = Array.isArray(r.providerUserInfo) ? r.providerUserInfo.filter(isObj) : [];
|
|
292
|
+
const hash = s(r.passwordHash);
|
|
293
|
+
rows.push(clean({
|
|
294
|
+
email_address: email,
|
|
295
|
+
email_verified: truthy(r.emailVerified),
|
|
296
|
+
...splitName(s(r.displayName)),
|
|
297
|
+
external_id: s(r.localId),
|
|
298
|
+
created_at: s(r.createdAt) ? Number(s(r.createdAt)) : undefined,
|
|
299
|
+
password_hash: hash,
|
|
300
|
+
password_salt: hash ? (s(r.salt) ?? '') : undefined,
|
|
301
|
+
password_hasher: hash ? 'firebase_scrypt' : undefined,
|
|
302
|
+
phone_numbers: s(r.phoneNumber) ? [{ phone_number: s(r.phoneNumber), verified: true }] : [],
|
|
303
|
+
external_accounts: providers
|
|
304
|
+
.filter((p) => s(p.providerId) && s(p.providerId) !== 'password' && s(p.providerId) !== 'phone' && s(p.rawId))
|
|
305
|
+
.map((p) => ({ provider: atlasProvider(s(p.providerId)), provider_user_id: s(p.rawId), email: s(p.email) })),
|
|
306
|
+
public_metadata: json(r.customAttributes),
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
if (skipped)
|
|
310
|
+
warnings.push(`${skipped} record(s) without an email address were skipped.`);
|
|
311
|
+
if (disabled)
|
|
312
|
+
warnings.push(`${disabled} user(s) were disabled in Firebase; ban them in Atlas after the import if they should stay blocked.`);
|
|
313
|
+
if (rows.some((r) => r.password_hash)) {
|
|
314
|
+
warnings.push('Firebase password hashes need the project hash parameters: pass --hash-config with signer_key, salt_separator, rounds and mem_cost.');
|
|
315
|
+
}
|
|
316
|
+
return { rows, warnings };
|
|
317
|
+
}
|
|
318
|
+
/** Amazon Cognito: `aws cognito-idp list-users` JSON, or the user-pool CSV format. Hashes cannot be exported. */
|
|
319
|
+
function mapCognito(doc) {
|
|
320
|
+
const warnings = [
|
|
321
|
+
'Cognito never exports password hashes. Use lazy migration (verify on first sign-in against your user pool) or send password resets.',
|
|
322
|
+
];
|
|
323
|
+
const rows = [];
|
|
324
|
+
let skipped = 0;
|
|
325
|
+
for (const r of listOf(doc, 'Users', 'users')) {
|
|
326
|
+
const attrs = { ...r };
|
|
327
|
+
const list = Array.isArray(r.Attributes) ? r.Attributes : Array.isArray(r.UserAttributes) ? r.UserAttributes : [];
|
|
328
|
+
for (const a of list.filter(isObj))
|
|
329
|
+
if (s(a.Name))
|
|
330
|
+
attrs[s(a.Name)] = a.Value;
|
|
331
|
+
const email = s(attrs.email);
|
|
332
|
+
if (!email) {
|
|
333
|
+
skipped++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const custom = Object.fromEntries(Object.entries(attrs)
|
|
337
|
+
.filter(([k]) => k.startsWith('custom:'))
|
|
338
|
+
.map(([k, v]) => [k.slice('custom:'.length), v]));
|
|
339
|
+
rows.push(clean({
|
|
340
|
+
email_address: email,
|
|
341
|
+
email_verified: truthy(attrs.email_verified),
|
|
342
|
+
first_name: s(attrs.given_name) ?? splitName(s(attrs.name)).first_name,
|
|
343
|
+
last_name: s(attrs.family_name) ?? (s(attrs.given_name) ? undefined : splitName(s(attrs.name)).last_name),
|
|
344
|
+
username: s(attrs.preferred_username),
|
|
345
|
+
external_id: s(attrs.sub) ?? s(r.Username) ?? s(attrs['cognito:username']),
|
|
346
|
+
created_at: s(r.UserCreateDate),
|
|
347
|
+
phone_numbers: s(attrs.phone_number) ? [{ phone_number: s(attrs.phone_number), verified: truthy(attrs.phone_number_verified) }] : [],
|
|
348
|
+
public_metadata: Object.keys(custom).length ? custom : undefined,
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
if (skipped)
|
|
352
|
+
warnings.push(`${skipped} record(s) without an email address were skipped.`);
|
|
353
|
+
return { rows, warnings };
|
|
354
|
+
}
|
|
355
|
+
/** Auth.js account types that are not a social identity (password, magic link, passkey). */
|
|
356
|
+
const NON_SOCIAL_AUTHJS = new Set(['credentials', 'email', 'webauthn']);
|
|
357
|
+
/** Auth.js / NextAuth: the adapter's `users` and `accounts` tables (JSON), or users with nested accounts. */
|
|
358
|
+
function mapAuthJs(doc) {
|
|
359
|
+
const warnings = [];
|
|
360
|
+
const users = listOf(doc, 'users', 'User', 'user');
|
|
361
|
+
const accounts = isObj(doc) ? listOf(doc.accounts ?? doc.Account ?? doc.account) : [];
|
|
362
|
+
const byUser = new Map();
|
|
363
|
+
for (const a of accounts) {
|
|
364
|
+
const uid = s(a.userId) ?? s(a.user_id);
|
|
365
|
+
if (uid)
|
|
366
|
+
byUser.set(uid, [...(byUser.get(uid) ?? []), a]);
|
|
367
|
+
}
|
|
368
|
+
const rows = [];
|
|
369
|
+
let skipped = 0;
|
|
370
|
+
for (const u of users) {
|
|
371
|
+
const email = s(u.email);
|
|
372
|
+
if (!email) {
|
|
373
|
+
skipped++;
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const linked = [...(Array.isArray(u.accounts) ? u.accounts.filter(isObj) : []), ...(byUser.get(s(u.id) ?? '') ?? [])];
|
|
377
|
+
rows.push(clean({
|
|
378
|
+
email_address: email,
|
|
379
|
+
email_verified: Boolean(s(u.emailVerified) ?? s(u.email_verified)),
|
|
380
|
+
...splitName(s(u.name)),
|
|
381
|
+
external_id: s(u.id),
|
|
382
|
+
created_at: s(u.createdAt) ?? s(u.created_at),
|
|
383
|
+
password_hash: s(u.password) ?? s(u.passwordHash) ?? s(u.hashedPassword) ?? s(u.password_hash),
|
|
384
|
+
external_accounts: linked
|
|
385
|
+
.filter((a) => !NON_SOCIAL_AUTHJS.has(s(a.type) ?? 'oauth') && s(a.provider) && (s(a.providerAccountId) ?? s(a.provider_account_id)))
|
|
386
|
+
.map((a) => ({
|
|
387
|
+
provider: atlasProvider(s(a.provider)),
|
|
388
|
+
provider_user_id: (s(a.providerAccountId) ?? s(a.provider_account_id)),
|
|
389
|
+
})),
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
if (skipped)
|
|
393
|
+
warnings.push(`${skipped} record(s) without an email address were skipped.`);
|
|
394
|
+
return { rows, warnings };
|
|
395
|
+
}
|
|
396
|
+
/** WorkOS: User Management users (API list JSON), optionally with nested organization memberships. */
|
|
397
|
+
function mapWorkos(doc) {
|
|
398
|
+
const warnings = [];
|
|
399
|
+
const rows = [];
|
|
400
|
+
const memberships = isObj(doc) ? listOf(doc.organization_memberships) : [];
|
|
401
|
+
const byUser = new Map();
|
|
402
|
+
for (const m of memberships) {
|
|
403
|
+
const uid = s(m.user_id);
|
|
404
|
+
if (uid)
|
|
405
|
+
byUser.set(uid, [...(byUser.get(uid) ?? []), m]);
|
|
406
|
+
}
|
|
407
|
+
// OAuth identities (`GET /user_management/users/:id/identities`), top-level
|
|
408
|
+
// with a user_id, or nested on each user.
|
|
409
|
+
const identities = isObj(doc) ? listOf(doc.identities) : [];
|
|
410
|
+
const idsByUser = new Map();
|
|
411
|
+
for (const i of identities) {
|
|
412
|
+
const uid = s(i.user_id);
|
|
413
|
+
if (uid)
|
|
414
|
+
idsByUser.set(uid, [...(idsByUser.get(uid) ?? []), i]);
|
|
415
|
+
}
|
|
416
|
+
let skipped = 0;
|
|
417
|
+
for (const u of listOf(doc, 'data', 'users')) {
|
|
418
|
+
const email = s(u.email);
|
|
419
|
+
if (!email) {
|
|
420
|
+
skipped++;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const ms = [...(Array.isArray(u.organization_memberships) ? u.organization_memberships.filter(isObj) : []), ...(byUser.get(s(u.id) ?? '') ?? [])];
|
|
424
|
+
const ids = [...(Array.isArray(u.identities) ? u.identities.filter(isObj) : []), ...(idsByUser.get(s(u.id) ?? '') ?? [])];
|
|
425
|
+
rows.push(clean({
|
|
426
|
+
email_address: email,
|
|
427
|
+
email_verified: truthy(u.email_verified),
|
|
428
|
+
first_name: s(u.first_name),
|
|
429
|
+
last_name: s(u.last_name),
|
|
430
|
+
external_id: s(u.external_id) ?? s(u.id),
|
|
431
|
+
created_at: s(u.created_at),
|
|
432
|
+
password_hash: s(u.password_hash),
|
|
433
|
+
external_accounts: ids
|
|
434
|
+
.filter((i) => s(i.provider) && s(i.idp_id))
|
|
435
|
+
.map((i) => ({ provider: atlasProvider(s(i.provider)), provider_user_id: s(i.idp_id) })),
|
|
436
|
+
organization_memberships: ms.map((m) => ({
|
|
437
|
+
organization_external_id: s(m.organization_id),
|
|
438
|
+
organization_name: s(m.organization_name),
|
|
439
|
+
role: isObj(m.role) ? s(m.role.slug) : s(m.role),
|
|
440
|
+
})),
|
|
441
|
+
public_metadata: json(u.metadata),
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
if (skipped)
|
|
445
|
+
warnings.push(`${skipped} record(s) without an email address were skipped.`);
|
|
446
|
+
return { rows, warnings };
|
|
447
|
+
}
|
|
448
|
+
/** Better Auth: a JSON dump of its tables (user, account, member, organization, twoFactor). */
|
|
449
|
+
function mapBetterAuth(doc) {
|
|
450
|
+
const warnings = [];
|
|
451
|
+
const t = (name) => (isObj(doc) ? listOf(doc[name] ?? doc[`${name}s`]) : []);
|
|
452
|
+
const users = Array.isArray(doc) ? listOf(doc) : t('user');
|
|
453
|
+
const accounts = t('account');
|
|
454
|
+
const members = t('member');
|
|
455
|
+
const orgs = new Map(t('organization').map((o) => [s(o.id) ?? '', o]));
|
|
456
|
+
const accountsBy = new Map();
|
|
457
|
+
for (const a of accounts) {
|
|
458
|
+
const uid = s(a.userId) ?? s(a.user_id);
|
|
459
|
+
if (uid)
|
|
460
|
+
accountsBy.set(uid, [...(accountsBy.get(uid) ?? []), a]);
|
|
461
|
+
}
|
|
462
|
+
const membersBy = new Map();
|
|
463
|
+
for (const m of members) {
|
|
464
|
+
const uid = s(m.userId) ?? s(m.user_id);
|
|
465
|
+
if (uid)
|
|
466
|
+
membersBy.set(uid, [...(membersBy.get(uid) ?? []), m]);
|
|
467
|
+
}
|
|
468
|
+
if (t('twoFactor').length) {
|
|
469
|
+
warnings.push(`${t('twoFactor').length} user(s) had two-factor enabled. Better Auth encrypts TOTP secrets with your app secret, so they re-enrol after the move.`);
|
|
470
|
+
}
|
|
471
|
+
const rows = [];
|
|
472
|
+
let skipped = 0;
|
|
473
|
+
for (const u of users) {
|
|
474
|
+
const email = s(u.email);
|
|
475
|
+
const uid = s(u.id) ?? '';
|
|
476
|
+
if (!email) {
|
|
477
|
+
skipped++;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const linked = accountsBy.get(uid) ?? [];
|
|
481
|
+
const credential = linked.find((a) => (s(a.providerId) ?? s(a.provider_id)) === 'credential');
|
|
482
|
+
rows.push(clean({
|
|
483
|
+
email_address: email,
|
|
484
|
+
email_verified: truthy(u.emailVerified ?? u.email_verified),
|
|
485
|
+
...splitName(s(u.name)),
|
|
486
|
+
username: s(u.username),
|
|
487
|
+
external_id: uid || undefined,
|
|
488
|
+
created_at: s(u.createdAt) ?? s(u.created_at),
|
|
489
|
+
password_hash: s(credential?.password),
|
|
490
|
+
phone_numbers: s(u.phoneNumber) ? [{ phone_number: s(u.phoneNumber), verified: truthy(u.phoneNumberVerified) }] : [],
|
|
491
|
+
external_accounts: linked
|
|
492
|
+
.filter((a) => a !== credential && s(a.providerId ?? a.provider_id) && s(a.accountId ?? a.account_id))
|
|
493
|
+
.map((a) => ({ provider: atlasProvider(s(a.providerId ?? a.provider_id)), provider_user_id: s(a.accountId ?? a.account_id) })),
|
|
494
|
+
organization_memberships: (membersBy.get(uid) ?? []).map((m) => {
|
|
495
|
+
const orgId = s(m.organizationId) ?? s(m.organization_id);
|
|
496
|
+
const org = orgs.get(orgId ?? '');
|
|
497
|
+
return {
|
|
498
|
+
organization_external_id: orgId,
|
|
499
|
+
organization_slug: s(org?.slug),
|
|
500
|
+
organization_name: s(org?.name),
|
|
501
|
+
role: s(m.role)?.split(',')[0],
|
|
502
|
+
};
|
|
503
|
+
}),
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
506
|
+
if (skipped)
|
|
507
|
+
warnings.push(`${skipped} record(s) without an email address were skipped.`);
|
|
508
|
+
return { rows, warnings };
|
|
509
|
+
}
|
|
510
|
+
const MAPPERS = {
|
|
511
|
+
auth0: mapAuth0,
|
|
512
|
+
clerk: mapClerk,
|
|
513
|
+
supabase: mapSupabase,
|
|
514
|
+
firebase: mapFirebase,
|
|
515
|
+
cognito: mapCognito,
|
|
516
|
+
authjs: mapAuthJs,
|
|
517
|
+
workos: mapWorkos,
|
|
518
|
+
'better-auth': mapBetterAuth,
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* Map a parsed vendor export. `hashes` is an optional second file of password
|
|
522
|
+
* hashes keyed by email (Auth0's support export, WorkOS's, or any `{email,
|
|
523
|
+
* password_hash}` list), merged onto the rows.
|
|
524
|
+
*/
|
|
525
|
+
function mapExport(vendor, doc, hashes) {
|
|
526
|
+
const mapped = MAPPERS[vendor](doc);
|
|
527
|
+
if (hashes !== undefined) {
|
|
528
|
+
const byEmail = new Map();
|
|
529
|
+
for (const h of listOf(hashes, 'users', 'data')) {
|
|
530
|
+
const email = s(h.email)?.toLowerCase();
|
|
531
|
+
if (email)
|
|
532
|
+
byEmail.set(email, h);
|
|
533
|
+
}
|
|
534
|
+
let merged = 0;
|
|
535
|
+
for (const row of mapped.rows) {
|
|
536
|
+
const h = byEmail.get(row.email_address.toLowerCase());
|
|
537
|
+
const hash = h && (s(h.passwordHash) ?? s(h.password_hash) ?? s(h.password_digest));
|
|
538
|
+
if (hash) {
|
|
539
|
+
row.password_hash = hash;
|
|
540
|
+
const type = s(h.password_hash_type) ?? s(h.password_hasher);
|
|
541
|
+
if (type)
|
|
542
|
+
row.password_hasher = type;
|
|
543
|
+
merged++;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
mapped.warnings = mapped.warnings.filter((w) => !w.startsWith('No password hashes found'));
|
|
547
|
+
mapped.warnings.push(`Merged ${merged} password hash(es) from the hash file.`);
|
|
548
|
+
}
|
|
549
|
+
return mapped;
|
|
550
|
+
}
|
|
551
|
+
/** A one-screen summary of what an import would do (the dry run). */
|
|
552
|
+
function summarize(rows) {
|
|
553
|
+
const orgs = new Set();
|
|
554
|
+
for (const r of rows)
|
|
555
|
+
for (const m of r.organization_memberships ?? [])
|
|
556
|
+
orgs.add(m.organization_external_id ?? m.organization_slug ?? m.organization_id ?? '');
|
|
557
|
+
orgs.delete('');
|
|
558
|
+
return {
|
|
559
|
+
users: rows.length,
|
|
560
|
+
verified_emails: rows.filter((r) => r.email_verified).length,
|
|
561
|
+
with_password_hash: rows.filter((r) => r.password_hash).length,
|
|
562
|
+
with_social_accounts: rows.filter((r) => r.external_accounts?.length).length,
|
|
563
|
+
with_phone_numbers: rows.filter((r) => r.phone_numbers?.length).length,
|
|
564
|
+
with_org_memberships: rows.filter((r) => r.organization_memberships?.length).length,
|
|
565
|
+
organizations: orgs.size,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
//# sourceMappingURL=vendors.js.map
|