@faable/faable 3.3.2 → 3.4.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.
@@ -0,0 +1,116 @@
1
+ import { openSync, fchmodSync, writeSync, closeSync } from 'node:fs';
2
+ import prompts from 'prompts';
3
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
4
+ import { log } from '../../../log.js';
5
+ import { tenant_options } from '../options.js';
6
+
7
+ // GET /user/export pages at most this many users.
8
+ const PAGE_SIZE = 500;
9
+ const users_export = {
10
+ command: 'export [file]',
11
+ describe: 'Export every user as NDJSON, optionally with password hashes (the input of `faable auth users import --from faable`)',
12
+ builder: yargs => tenant_options(yargs)
13
+ .positional('file', {
14
+ type: 'string',
15
+ description: 'Output file (created with mode 0600). Omit to write to stdout'
16
+ })
17
+ .option('include-hashes', {
18
+ type: 'boolean',
19
+ default: false,
20
+ description: 'Include password hashes. Needs the read:password_hashes permission, is audited and notifies the tenant credentials.exported subscribers'
21
+ })
22
+ .option('connection', {
23
+ type: 'string',
24
+ description: 'Database connection (connection_name) whose hashes are exported. Defaults to the tenant database connection'
25
+ })
26
+ .option('force', {
27
+ type: 'boolean',
28
+ default: false,
29
+ description: 'Allow writing password hashes to a terminal'
30
+ })
31
+ .option('yes', {
32
+ alias: 'y',
33
+ type: 'boolean',
34
+ default: false,
35
+ description: 'Skip the confirmation prompt'
36
+ })
37
+ .example('$0 auth users export users.ndjson', 'Export profiles and identities')
38
+ .example('$0 auth users export users.ndjson --include-hashes', 'Export with password hashes (to move users to another tenant or provider)')
39
+ .showHelpOnFail(false),
40
+ handler: withAuthHints(async (args) => {
41
+ const hashes = !!args['include-hashes'];
42
+ const to_stdout = !args.file;
43
+ // A hash dump scrolling past in a terminal ends up in scrollback, screen
44
+ // recordings and shell logs.
45
+ if (hashes && to_stdout && process.stdout.isTTY && !args.force) {
46
+ throw new Error('Refusing to print password hashes to a terminal: pass a file, pipe stdout, or add --force');
47
+ }
48
+ if (hashes && !args.yes) {
49
+ // The prompt goes to stderr so a piped stdout stays pure NDJSON.
50
+ const { confirm } = await prompts({
51
+ type: 'toggle',
52
+ name: 'confirm',
53
+ message: 'Export password hashes? Anyone holding the file can attack every password offline. The export is audited and notifies the tenant subscribers.',
54
+ initial: false,
55
+ active: 'yes',
56
+ inactive: 'no',
57
+ stdout: process.stderr
58
+ }, { onCancel: () => false });
59
+ if (!confirm) {
60
+ process.stderr.write('Cancelled.\n');
61
+ return;
62
+ }
63
+ }
64
+ const api = await requireAuthAdmin(args);
65
+ // 0600: only the owner can read a file that holds password hashes. The
66
+ // mode of `openSync` only applies when it creates the file, so an existing
67
+ // one is tightened explicitly.
68
+ const fd = to_stdout ? 1 : openSync(args.file, 'w', 0o600);
69
+ if (!to_stdout)
70
+ fchmodSync(fd, 0o600);
71
+ const counts = { users: 0, with_hash: 0, legacy: 0, not_set: 0, no_credential: 0 };
72
+ try {
73
+ let cursor;
74
+ do {
75
+ const page = await api.userExport({
76
+ cursor,
77
+ limit: PAGE_SIZE,
78
+ connection: args.connection,
79
+ include_hashes: hashes
80
+ });
81
+ for (const row of page.users) {
82
+ writeSync(fd, JSON.stringify(row) + '\n');
83
+ counts.users += 1;
84
+ if (row.password_hash)
85
+ counts.with_hash += 1;
86
+ if (row.password_hash_status === 'legacy_unexportable')
87
+ counts.legacy += 1;
88
+ if (row.password_hash_status === 'not_set')
89
+ counts.not_set += 1;
90
+ if (row.password_hash_status === 'no_credential')
91
+ counts.no_credential += 1;
92
+ }
93
+ cursor = page.next_cursor ?? undefined;
94
+ if (!to_stdout)
95
+ log.info(`📤 ${counts.users} user(s)…`);
96
+ } while (cursor);
97
+ }
98
+ finally {
99
+ if (!to_stdout)
100
+ closeSync(fd);
101
+ }
102
+ // The report goes to stderr: stdout may be the export itself.
103
+ const report = [
104
+ `Exported ${counts.users} user(s)${to_stdout ? '' : ` to ${args.file}`}.`,
105
+ hashes ? `${counts.with_hash} with a password hash.` : 'No password hashes (add --include-hashes).',
106
+ counts.legacy
107
+ ? `${counts.legacy} still use a pre-2026 password format that is not exported: they need a password reset, or to sign in once before you export again.`
108
+ : '',
109
+ counts.not_set ? `${counts.not_set} never set a password.` : '',
110
+ counts.no_credential ? `${counts.no_credential} have no password (social or passwordless only).` : ''
111
+ ].filter(Boolean);
112
+ process.stderr.write(report.join('\n') + '\n');
113
+ })
114
+ };
115
+
116
+ export { users_export };
@@ -0,0 +1,261 @@
1
+ const SOURCES = [
2
+ 'auth0',
3
+ 'clerk',
4
+ 'firebase',
5
+ 'supabase',
6
+ 'keycloak',
7
+ 'faable'
8
+ ];
9
+ const SOURCE_FORMAT = {
10
+ auth0: 'json',
11
+ clerk: 'csv',
12
+ firebase: 'json',
13
+ // A Supabase table dump can be either; `--format` overrides.
14
+ supabase: 'csv',
15
+ keycloak: 'json',
16
+ faable: 'json'
17
+ };
18
+ const str = (v) => typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
19
+ const bool = (v) => {
20
+ if (typeof v === 'boolean')
21
+ return v;
22
+ if (v === 'true' || v === 't' || v === '1')
23
+ return true;
24
+ if (v === 'false' || v === 'f' || v === '0')
25
+ return false;
26
+ return undefined;
27
+ };
28
+ const obj = (v) => {
29
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
30
+ return v;
31
+ }
32
+ if (typeof v === 'string' && v.trim().startsWith('{')) {
33
+ try {
34
+ return JSON.parse(v);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ return undefined;
41
+ };
42
+ const unpad = (b64) => b64.replace(/=+$/, '');
43
+ // Drops undefined keys so the row stays small and `additionalProperties`
44
+ // never sees a `null` where the server expects a string.
45
+ const clean = (row) => Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined && v !== null));
46
+ const map_connection = (name, opts) => opts.connection_map?.[name] ?? name;
47
+ const with_email = (row) => row.email ? { row: clean(row) } : { skip: 'no email' };
48
+ // ── Auth0 ──────────────────────────────────────────────────────────────────
49
+ // The password-hash export Auth0 support hands over (NDJSON, `passwordHash`
50
+ // in bcrypt), or its own bulk-import shape (`custom_password_hash`).
51
+ const auth0 = (r, opts) => {
52
+ const custom = obj(r.custom_password_hash);
53
+ const custom_hash = obj(custom?.hash);
54
+ const password_hash = str(r.passwordHash) ??
55
+ str(r.password_hash) ??
56
+ (custom && str(custom_hash?.value)
57
+ ? String(custom_hash.value)
58
+ : undefined);
59
+ const identities = (Array.isArray(r.identities) ? r.identities : [])
60
+ .map(obj)
61
+ .filter((i) => !!i && i.provider !== 'auth0' && i.user_id !== undefined)
62
+ .map(i => ({
63
+ connection: map_connection(String(i.connection ?? i.provider), opts),
64
+ identity_id: String(i.user_id)
65
+ }));
66
+ // A social-only user exported without `identities`: `provider|id`.
67
+ const user_id = str(r.user_id);
68
+ if (!identities.length && user_id?.includes('|') && !user_id.startsWith('auth0|')) {
69
+ const [provider, id] = user_id.split('|');
70
+ identities.push({ connection: map_connection(provider, opts), identity_id: id });
71
+ }
72
+ return with_email({
73
+ email: str(r.email),
74
+ email_verified: bool(r.email_verified),
75
+ name: str(r.name),
76
+ given_name: str(r.given_name),
77
+ family_name: str(r.family_name),
78
+ picture: str(r.picture),
79
+ user_metadata: obj(r.user_metadata),
80
+ app_metadata: obj(r.app_metadata),
81
+ password_hash,
82
+ identities: identities.length ? identities : undefined
83
+ });
84
+ };
85
+ // ── Clerk ──────────────────────────────────────────────────────────────────
86
+ // The CSV from the Clerk dashboard: `password_digest` + `password_hasher`.
87
+ const clerk_hash = (digest, hasher) => {
88
+ if (!digest)
89
+ return undefined;
90
+ switch (hasher) {
91
+ // Self-describing digests go as they are. Clerk keeps Django's
92
+ // `pbkdf2_sha256$iterations$salt$hash` unchanged.
93
+ case 'bcrypt':
94
+ case 'argon2id':
95
+ case 'argon2i':
96
+ case 'pbkdf2_sha256':
97
+ case 'pbkdf2_sha256_django':
98
+ case undefined:
99
+ return digest;
100
+ case 'scrypt_firebase': {
101
+ // `<hash>$<salt>$<signer key>$<salt separator>$<rounds>$<mem cost>`
102
+ const [hash, salt, signer_key, salt_separator, rounds, mem_cost] = digest.split('$');
103
+ return {
104
+ algorithm: 'firebase-scrypt',
105
+ hash,
106
+ salt,
107
+ params: { signer_key, salt_separator, rounds, mem_cost }
108
+ };
109
+ }
110
+ default:
111
+ // md5, sha256, bcrypt_sha256_django…: the server answers
112
+ // weak_password_hash for that row.
113
+ return { algorithm: hasher, hash: digest };
114
+ }
115
+ };
116
+ const clerk = (r) => {
117
+ const email = str(r.primary_email_address) ?? str(r.email_address);
118
+ const verified = String(r.verified_email_addresses ?? '')
119
+ .split(/[|,;\s]+/)
120
+ .filter(Boolean);
121
+ const given_name = str(r.first_name);
122
+ const family_name = str(r.last_name);
123
+ return with_email({
124
+ email,
125
+ email_verified: email ? verified.includes(email) : undefined,
126
+ given_name,
127
+ family_name,
128
+ name: [given_name, family_name].filter(Boolean).join(' ') || undefined,
129
+ phone: str(r.primary_phone_number),
130
+ password_hash: clerk_hash(str(r.password_digest), str(r.password_hasher))
131
+ });
132
+ };
133
+ // ── Firebase ───────────────────────────────────────────────────────────────
134
+ // `firebase auth:export users.json --format=json`. The hash parameters are
135
+ // project-wide and live in the console (Authentication → Users → ⋮ →
136
+ // Password hash parameters), not in the export.
137
+ const FIREBASE_PROVIDERS = {
138
+ 'google.com': 'google',
139
+ 'github.com': 'github',
140
+ 'facebook.com': 'facebook',
141
+ 'apple.com': 'apple',
142
+ 'twitter.com': 'twitter',
143
+ 'microsoft.com': 'microsoft'
144
+ };
145
+ const firebase = (r, opts) => {
146
+ let password_hash;
147
+ const hash = str(r.passwordHash);
148
+ if (hash) {
149
+ if (!opts.firebase) {
150
+ throw new Error('Firebase exports need the project hash parameters: --hash-key, --salt-separator, --rounds and --mem-cost (Firebase console → Authentication → Users → ⋮ → Password hash parameters)');
151
+ }
152
+ password_hash = {
153
+ algorithm: 'firebase-scrypt',
154
+ hash,
155
+ salt: str(r.salt) ?? '',
156
+ params: { ...opts.firebase }
157
+ };
158
+ }
159
+ const identities = (Array.isArray(r.providerUserInfo) ? r.providerUserInfo : [])
160
+ .map(obj)
161
+ .filter((p) => !!p && p.providerId !== 'password' && p.providerId !== 'phone')
162
+ .map(p => {
163
+ const provider = String(p.providerId);
164
+ return {
165
+ connection: map_connection(FIREBASE_PROVIDERS[provider] ?? provider.replace(/\.com$/, ''), opts),
166
+ identity_id: String(p.rawId)
167
+ };
168
+ });
169
+ return with_email({
170
+ email: str(r.email),
171
+ email_verified: bool(r.emailVerified),
172
+ name: str(r.displayName),
173
+ picture: str(r.photoUrl),
174
+ phone: str(r.phoneNumber),
175
+ password_hash,
176
+ identities: identities.length ? identities : undefined
177
+ });
178
+ };
179
+ // ── Supabase ───────────────────────────────────────────────────────────────
180
+ // A dump of `auth.users` (CSV or JSON): `encrypted_password` is bcrypt.
181
+ const supabase = (r) => {
182
+ const meta = obj(r.raw_user_meta_data);
183
+ return with_email({
184
+ email: str(r.email),
185
+ email_verified: !!str(r.email_confirmed_at) || bool(r.email_verified),
186
+ name: str(meta?.full_name) ?? str(meta?.name),
187
+ picture: str(meta?.avatar_url) ?? str(meta?.picture),
188
+ phone: str(r.phone),
189
+ user_metadata: meta,
190
+ password_hash: str(r.encrypted_password)
191
+ });
192
+ };
193
+ // ── Keycloak ───────────────────────────────────────────────────────────────
194
+ // A realm export (`kc.sh export --users realm_file`), or one of its
195
+ // `<realm>-users-N.json` files. Password credentials keep the hash in
196
+ // `secretData` and the algorithm in `credentialData`, both JSON strings.
197
+ const keycloak_hash = (credential) => {
198
+ const secret = obj(credential.secretData);
199
+ const data = obj(credential.credentialData);
200
+ const value = str(secret?.value);
201
+ const salt = str(secret?.salt);
202
+ if (!value || !data)
203
+ return undefined;
204
+ const algorithm = String(data.algorithm ?? '');
205
+ const iterations = Number(data.hashIterations);
206
+ const extra = (obj(data.additionalParameters) ?? {});
207
+ const first = (k) => Array.isArray(extra[k]) ? String(extra[k][0]) : undefined;
208
+ if (algorithm === 'argon2') {
209
+ // Keycloak 24+: parameters split out; rebuild the PHC string.
210
+ const type = first('type') ?? 'id';
211
+ const memory = first('memory') ?? '7168';
212
+ const parallelism = first('parallelism') ?? '1';
213
+ return `$argon2${type}$v=19$m=${memory},t=${iterations},p=${parallelism}$${unpad(salt ?? '')}$${unpad(value)}`;
214
+ }
215
+ return {
216
+ // `pbkdf2` (no suffix) is PBKDF2-SHA1: sent as such, refused as weak.
217
+ algorithm: algorithm === 'pbkdf2' ? 'pbkdf2-sha1' : algorithm,
218
+ hash: value,
219
+ salt,
220
+ params: { iterations }
221
+ };
222
+ };
223
+ const keycloak = (r, opts) => {
224
+ const credential = (Array.isArray(r.credentials) ? r.credentials : [])
225
+ .map(obj)
226
+ .find(c => c?.type === 'password');
227
+ const identities = (Array.isArray(r.federatedIdentities) ? r.federatedIdentities : [])
228
+ .map(obj)
229
+ .filter((i) => !!i)
230
+ .map(i => ({
231
+ connection: map_connection(String(i.identityProvider), opts),
232
+ identity_id: String(i.userId)
233
+ }));
234
+ const given_name = str(r.firstName);
235
+ const family_name = str(r.lastName);
236
+ return with_email({
237
+ email: str(r.email),
238
+ email_verified: bool(r.emailVerified),
239
+ given_name,
240
+ family_name,
241
+ name: [given_name, family_name].filter(Boolean).join(' ') || undefined,
242
+ password_hash: credential ? keycloak_hash(credential) : undefined,
243
+ identities: identities.length ? identities : undefined
244
+ });
245
+ };
246
+ // ── Faable ─────────────────────────────────────────────────────────────────
247
+ // `faable auth users export` output: already the import shape. The server
248
+ // drops `user_id` / `password_hash_status` itself.
249
+ const faable = (r, opts) => {
250
+ const row = { ...r };
251
+ if (Array.isArray(row.identities)) {
252
+ row.identities = row.identities.map(i => ({
253
+ ...i,
254
+ connection: map_connection(i.connection, opts)
255
+ }));
256
+ }
257
+ return with_email(row);
258
+ };
259
+ const ADAPTERS = { auth0, clerk, firebase, supabase, keycloak, faable };
260
+
261
+ export { ADAPTERS, SOURCES, SOURCE_FORMAT };
@@ -0,0 +1,98 @@
1
+ // Reading an export file into plain records, before any provider-specific
2
+ // mapping. Exports come as a JSON array, a JSON object wrapping the array
3
+ // (`{ "users": [...] }` — Firebase, Keycloak), NDJSON (Auth0, `faable auth
4
+ // users export`) or CSV (Clerk, a Supabase table dump).
5
+ // RFC 4180: comma-separated, `"` quotes a field, `""` is a literal quote, and
6
+ // quoted fields may span lines. The first row is the header.
7
+ const parse_csv = (text) => {
8
+ const rows = [];
9
+ let row = [];
10
+ let field = '';
11
+ let quoted = false;
12
+ const input = text.replace(/^\uFEFF/, '');
13
+ for (let i = 0; i < input.length; i++) {
14
+ const c = input[i];
15
+ if (quoted) {
16
+ if (c === '"') {
17
+ if (input[i + 1] === '"') {
18
+ field += '"';
19
+ i++;
20
+ }
21
+ else {
22
+ quoted = false;
23
+ }
24
+ }
25
+ else {
26
+ field += c;
27
+ }
28
+ }
29
+ else if (c === '"' && field === '') {
30
+ quoted = true;
31
+ }
32
+ else if (c === ',') {
33
+ row.push(field);
34
+ field = '';
35
+ }
36
+ else if (c === '\n' || c === '\r') {
37
+ if (c === '\r' && input[i + 1] === '\n')
38
+ i++;
39
+ row.push(field);
40
+ rows.push(row);
41
+ row = [];
42
+ field = '';
43
+ }
44
+ else {
45
+ field += c;
46
+ }
47
+ }
48
+ if (quoted)
49
+ throw new Error('CSV: unterminated quoted field');
50
+ if (field !== '' || row.length > 0) {
51
+ row.push(field);
52
+ rows.push(row);
53
+ }
54
+ const [header, ...body] = rows.filter(r => !(r.length === 1 && r[0] === ''));
55
+ if (!header)
56
+ return [];
57
+ return body.map((cells, line) => {
58
+ if (cells.length !== header.length) {
59
+ throw new Error(`CSV: row ${line + 2} has ${cells.length} fields, the header has ${header.length}`);
60
+ }
61
+ return Object.fromEntries(header.map((h, i) => [h.trim(), cells[i]]));
62
+ });
63
+ };
64
+ const parse_records = (text, format) => {
65
+ if (format === 'csv')
66
+ return parse_csv(text);
67
+ const trimmed = text.trim();
68
+ if (!trimmed)
69
+ return [];
70
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
71
+ try {
72
+ const parsed = JSON.parse(trimmed);
73
+ if (Array.isArray(parsed))
74
+ return parsed;
75
+ if (Array.isArray(parsed?.users))
76
+ return parsed.users;
77
+ // A single object on one line is NDJSON with one record.
78
+ if (!trimmed.includes('\n'))
79
+ return [parsed];
80
+ }
81
+ catch {
82
+ // Not one JSON document: fall through to NDJSON.
83
+ }
84
+ }
85
+ return trimmed
86
+ .split(/\r?\n/)
87
+ .filter(line => line.trim())
88
+ .map((line, i) => {
89
+ try {
90
+ return JSON.parse(line);
91
+ }
92
+ catch {
93
+ throw new Error(`NDJSON: line ${i + 1} is not valid JSON`);
94
+ }
95
+ });
96
+ };
97
+
98
+ export { parse_csv, parse_records };
@@ -0,0 +1,192 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import prompts from 'prompts';
3
+ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
4
+ import { log } from '../../../log.js';
5
+ import { json_option, tenant_options } from '../options.js';
6
+ import { print_json } from '../render.js';
7
+ import { SOURCES, ADAPTERS, SOURCE_FORMAT } from './import/adapters.js';
8
+ import { parse_records } from './import/records.js';
9
+
10
+ // The server takes at most this many rows per request (POST /user/import).
11
+ const BATCH_SIZE = 500;
12
+ const parse_connection_map = (pairs = []) => Object.fromEntries(pairs.map(pair => {
13
+ const [from, to] = pair.split('=');
14
+ if (!from || !to) {
15
+ throw new Error(`--map-connection expects provider=connection, got "${pair}"`);
16
+ }
17
+ return [from, to];
18
+ }));
19
+ const firebase_config = (args) => {
20
+ const given = [args['hash-key'], args['salt-separator'], args.rounds, args['mem-cost']];
21
+ if (given.every(v => v === undefined))
22
+ return undefined;
23
+ if (given.some(v => v === undefined)) {
24
+ throw new Error('--hash-key, --salt-separator, --rounds and --mem-cost go together');
25
+ }
26
+ return {
27
+ signer_key: args['hash-key'],
28
+ salt_separator: args['salt-separator'],
29
+ rounds: args.rounds,
30
+ mem_cost: args['mem-cost']
31
+ };
32
+ };
33
+ // Reads and adapts the whole file before anything is sent: a malformed file
34
+ // fails here, not after half of it went in.
35
+ const prepare_rows = (text, source, opts) => {
36
+ const records = parse_records(text, opts.format ?? SOURCE_FORMAT[source]);
37
+ const rows = [];
38
+ const skipped = [];
39
+ records.forEach((raw, i) => {
40
+ const adapted = ADAPTERS[source](raw, opts);
41
+ if ('skip' in adapted) {
42
+ skipped.push({
43
+ record: i + 1,
44
+ status: 'error',
45
+ error: { code: 'unusable_record', message: adapted.skip }
46
+ });
47
+ }
48
+ else {
49
+ rows.push({ record: i + 1, row: adapted.row });
50
+ }
51
+ });
52
+ return { total: records.length, rows, skipped };
53
+ };
54
+ const users_import = {
55
+ command: 'import <file>',
56
+ describe: 'Import users from another provider, with their password hashes (bcrypt, scrypt, Firebase scrypt, PBKDF2, argon2)',
57
+ builder: yargs => json_option(tenant_options(yargs))
58
+ .positional('file', {
59
+ type: 'string',
60
+ demandOption: true,
61
+ description: 'The export file of the provider you are leaving'
62
+ })
63
+ .option('from', {
64
+ choices: SOURCES,
65
+ demandOption: true,
66
+ description: 'Where the file comes from: auth0 (NDJSON with passwordHash), clerk (CSV), firebase (auth:export JSON), supabase (auth.users dump), keycloak (realm export), faable (faable auth users export)'
67
+ })
68
+ .option('format', {
69
+ choices: ['json', 'csv'],
70
+ description: 'Override the file format guessed from --from'
71
+ })
72
+ .option('connection', {
73
+ type: 'string',
74
+ description: 'Database connection (connection_name) the passwords go to. Defaults to the tenant database connection'
75
+ })
76
+ .option('map-connection', {
77
+ type: 'string',
78
+ array: true,
79
+ description: 'Rename a social provider to your connection_name, e.g. google-oauth2=google (repeatable)'
80
+ })
81
+ .option('update-existing', {
82
+ type: 'boolean',
83
+ default: false,
84
+ description: 'Set the password hash of users that already exist (same email) instead of skipping them'
85
+ })
86
+ .option('dry-run', {
87
+ type: 'boolean',
88
+ default: false,
89
+ description: 'Validate every row on the server without writing anything'
90
+ })
91
+ .option('hash-key', {
92
+ type: 'string',
93
+ description: 'Firebase: base64_signer_key from the password hash parameters'
94
+ })
95
+ .option('salt-separator', {
96
+ type: 'string',
97
+ description: 'Firebase: base64_salt_separator'
98
+ })
99
+ .option('rounds', { type: 'number', description: 'Firebase: rounds' })
100
+ .option('mem-cost', { type: 'number', description: 'Firebase: mem_cost' })
101
+ .option('yes', {
102
+ alias: 'y',
103
+ type: 'boolean',
104
+ default: false,
105
+ description: 'Skip the confirmation prompt'
106
+ })
107
+ .example('$0 auth users import auth0-users.ndjson --from auth0 --dry-run', 'Check an Auth0 export without writing anything')
108
+ .example('$0 auth users import users.json --from firebase --hash-key … --salt-separator Bw== --rounds 8 --mem-cost 14', 'Import a Firebase export with its hash parameters')
109
+ .example('$0 auth users import users.csv --from clerk --map-connection oauth_google=google -y', 'Import a Clerk CSV without prompting')
110
+ .showHelpOnFail(false),
111
+ handler: withAuthHints(async (args) => {
112
+ const text = await readFile(args.file, 'utf8');
113
+ const { total, rows, skipped } = prepare_rows(text, args.from, {
114
+ format: args.format,
115
+ connection_map: parse_connection_map(args['map-connection']),
116
+ firebase: firebase_config(args)
117
+ });
118
+ if (rows.length === 0) {
119
+ throw new Error(`No importable users in ${args.file} (${total} record(s) read)`);
120
+ }
121
+ const dry_run = !!args['dry-run'];
122
+ if (!args.yes && !dry_run) {
123
+ const { confirm } = await prompts({
124
+ type: 'toggle',
125
+ name: 'confirm',
126
+ message: `Import ${rows.length} user(s) from ${args.from}?`,
127
+ initial: false,
128
+ active: 'yes',
129
+ inactive: 'no'
130
+ });
131
+ if (!confirm) {
132
+ log.info('Cancelled.');
133
+ return;
134
+ }
135
+ }
136
+ const api = await requireAuthAdmin(args);
137
+ const results = [...skipped];
138
+ for (let start = 0; start < rows.length; start += BATCH_SIZE) {
139
+ const batch = rows.slice(start, start + BATCH_SIZE);
140
+ const res = await api.userImport({
141
+ users: batch.map(b => b.row),
142
+ connection: args.connection,
143
+ update_existing: !!args['update-existing'],
144
+ dry_run
145
+ });
146
+ for (const r of res.results) {
147
+ results.push({
148
+ record: batch[r.index].record,
149
+ email: r.email,
150
+ status: r.status,
151
+ user_id: r.user_id,
152
+ error: r.error
153
+ });
154
+ }
155
+ if (!args.json) {
156
+ log.info(`${dry_run ? '🔎 Checked' : '📥 Imported'} ${Math.min(start + BATCH_SIZE, rows.length)}/${rows.length}`);
157
+ }
158
+ }
159
+ results.sort((a, b) => a.record - b.record);
160
+ const count = (s) => results.filter(r => r.status === s).length;
161
+ const summary = {
162
+ records: total,
163
+ created: count('created'),
164
+ updated: count('updated'),
165
+ skipped: count('skipped'),
166
+ errors: count('error')
167
+ };
168
+ if (args.json) {
169
+ print_json({ dry_run, summary, results });
170
+ }
171
+ else {
172
+ for (const r of results.filter(r => r.status === 'error')) {
173
+ log.error(`❌ record ${r.record}${r.email ? ` (${r.email})` : ''}: ${r.error?.code} — ${r.error?.message}`);
174
+ }
175
+ log.info(`${dry_run ? 'Dry run — nothing written. Would create' : 'Created'} ${summary.created}, ${dry_run ? 'update' : 'updated'} ${summary.updated}, skipped ${summary.skipped} existing, ${summary.errors} error(s) out of ${summary.records} record(s).`);
176
+ if (summary.created + summary.updated > 0 && !dry_run) {
177
+ log.info('🔐 Imported passwords are upgraded to argon2id the first time each user signs in.');
178
+ }
179
+ }
180
+ if (summary.errors > 0) {
181
+ // In --json mode stdout must stay one JSON document: the global failure
182
+ // handler would append its message to it. The exit code carries it.
183
+ if (args.json) {
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ throw new Error(`${summary.errors} record(s) were not imported`);
188
+ }
189
+ })
190
+ };
191
+
192
+ export { BATCH_SIZE, prepare_rows, users_import };
@@ -1,16 +1,20 @@
1
+ import { users_export } from './export.js';
1
2
  import { users_get } from './get.js';
3
+ import { users_import } from './import.js';
2
4
  import { users_list } from './list.js';
3
5
  import { users_reinstate } from './reinstate.js';
4
6
  import { users_suspend } from './suspend.js';
5
7
 
6
8
  const users = {
7
9
  command: 'users',
8
- describe: 'List, inspect, suspend and reinstate users',
10
+ describe: 'List, inspect, suspend, reinstate, import and export users',
9
11
  builder: yargs => yargs
10
12
  .command(users_list)
11
13
  .command(users_get)
12
14
  .command(users_suspend)
13
15
  .command(users_reinstate)
16
+ .command(users_import)
17
+ .command(users_export)
14
18
  .demandCommand(1)
15
19
  .showHelpOnFail(false),
16
20
  handler: () => { }
package/dist/index.js CHANGED
@@ -24,8 +24,10 @@ yg.scriptName('faable')
24
24
  return;
25
25
  banner_shown = true;
26
26
  // --json mode is for piping: keep stdout machine-clean (no banner, no
27
- // update-check notice).
28
- if (argv.json)
27
+ // update-check notice). Same for `auth users export` without a file,
28
+ // whose stdout IS the export.
29
+ const exports_to_stdout = argv._.slice(0, 3).join(' ') === 'auth users export' && !argv.file;
30
+ if (argv.json || exports_to_stdout)
29
31
  return;
30
32
  log.info(`Faable CLI ${version}`);
31
33
  // `upgrade` does its own (forced) check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "3.3.2",
3
+ "version": "3.4.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "dependencies": {
29
29
  "@actions/core": "^3.0.0",
30
- "@faable/auth-sdk": "^2.6.3",
30
+ "@faable/auth-sdk": "^2.7.29",
31
31
  "axios": "^1.18.1",
32
32
  "fs-extra": "^11.3.2",
33
33
  "open": "^11.0.0",