smoonb 1.0.8 → 1.0.9

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 CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.0.9] - 2026-10-10
8
+
9
+ ### Fixed
10
+
11
+ - Treat SMTP passwords as write-only credentials. Never replay the protected SMTP value returned by the Management API from a legacy backup as the provider password.
12
+ - Ask for the real SMTP password with hidden input before restoring any selected components. Fail before remote mutations if it is missing. Keep the entered password in memory only.
13
+ - Verify readable Auth settings independently from password submission. Continue after the API accepts the supplied password, but report `applied_with_unverified_secrets` and require an authentication email test instead of claiming password or email-delivery verification.
14
+ - Omit the protected SMTP value from new Auth snapshots and explicitly record that the provider password is required during restore. Existing backups remain usable.
15
+
16
+ ### Recovery from 1.0.8
17
+
18
+ - If restore stopped at `Configuration verification failed: smtp_pass`, inspect its `report.json`. When Database and Edge Functions are already verified, run restore with those two components disabled and select Auth, Storage, Database Settings and Realtime as needed. Supply the real provider SMTP password when prompted; no new backup or destination project is required for this specific failure.
19
+
20
+ ### Tests
21
+
22
+ - Cover legacy/new snapshots, hidden input, missing-password preflight, protected-value rejection, HTTP failures, readable-setting mismatches, partial verification reporting and continuation without reloading the database.
23
+
7
24
  ## [1.0.8] - 2026-10-10
8
25
 
9
26
  ### Fixed
package/README.md CHANGED
@@ -313,7 +313,11 @@ Database loading uses one transaction, stops on errors, suspends triggers only o
313
313
 
314
314
  Optional URL remapping changes only the project's Supabase hostname, including URLs inside JSON and text files. It does not rename files or replace isolated project IDs. Historical URLs may also change; choose preservation when needed. Signed URLs, custom domains, external integrations and application API keys need separate validation. Destination platform keys are not replaced.
315
315
 
316
- Auth and Realtime are applied through the Management API and verified by reading them back. Legacy Realtime captures may be stale: restoration reproduces the recorded values and date, not the source project's current settings. New backups capture Realtime directly through the API. Legacy function backups without the JWT flag default to requiring JWT; new backups preserve that flag.
316
+ Readable Auth and Realtime settings are applied through the Management API and verified by reading them back. The **SMTP password cannot be exported**: Supabase returns a protected value that cannot be used as the password in another project. Starting with 1.0.9, smoonb requests the real provider password with hidden input **before restoring any components**, including when using legacy backups. It stays in memory only and is sent to the destination Supabase project. The report distinguishes API password acceptance from readback verification (`applied_with_unverified_secrets`). Test an authentication email on the destination; API acceptance does not validate the password with the email provider.
317
+
318
+ If a 1.0.8 restore stopped at `Configuration verification failed: smtp_pass` and its `report.json` confirms Database and Edge Functions, reuse the same backup and project: run `npx smoonb@1.0.9 restore`, answer **no** for Database and Edge Functions, and **yes** for the pending Auth, Storage, Database Settings and Realtime components. Enter the provider SMTP password when prompted. This specific failure does not require another backup or a database reload.
319
+
320
+ Legacy Realtime captures may be stale: restoration reproduces the recorded values and date, not the source project's current settings. New backups capture Realtime directly through the API. Legacy function backups without the JWT flag default to requiring JWT; new backups preserve that flag.
317
321
 
318
322
  Reports are saved after each stage, including failures. External services cannot share the database transaction: earlier stages remain completed if a later stage fails. Do not reload the database into a populated destination. Resolve the reported stage and select only pending components, or use another empty project.
319
323
 
package/README.pt-BR.md CHANGED
@@ -313,7 +313,11 @@ A carga do banco usa uma transação com interrupção em erro, suspende trigger
313
313
 
314
314
  O remapeamento opcional troca apenas o domínio Supabase do projeto em URLs, inclusive em JSON e arquivos de texto. Não troca IDs isolados nem nomes de arquivos. URLs históricas também podem ser afetadas; escolha preservar quando isso for necessário. URLs assinadas, domínios personalizados, integrações externas e chaves de API da aplicação precisam de validação específica. As chaves de plataforma do destino não são substituídas.
315
315
 
316
- Auth e Realtime são aplicados pela Management API e conferidos por nova leitura. A captura antiga de Realtime pode conter valores desatualizados: a restauração reproduz os valores e a data registrados, não o estado atual do projeto original. Novos backups capturam Realtime diretamente pela API. Em backups antigos sem o sinalizador de JWT de Functions, o padrão seguro é exigir JWT; os novos backups guardam esse sinalizador.
316
+ As configurações legíveis de Auth e Realtime são aplicadas pela Management API e conferidas por nova leitura. A **senha SMTP não é exportável**: o Supabase devolve um valor protegido, que não pode ser usado como senha em outro projeto. A partir da versão 1.0.9, o smoonb solicita a senha real do provedor em campo oculto **antes de restaurar os componentes**, inclusive ao usar backups antigos. Ela permanece somente na memória e é enviada ao Supabase de destino. O relatório distingue senha aceita pela API de configurações verificadas por leitura (`applied_with_unverified_secrets`). Teste o envio de e-mail de autenticação no destino; aceitar uma senha pela API não comprova sua validade junto ao provedor.
317
+
318
+ Se uma restauração 1.0.8 parou em `Configuration verification failed: smtp_pass` e o `report.json` confirmou Database e Edge Functions, use o mesmo backup e projeto: execute `npx smoonb@1.0.9 restore`, responda **não** para Database e Edge Functions e **sim** para Auth, Storage, Database Settings e Realtime que ficaram pendentes. Informe a senha SMTP do provedor quando solicitada. Não é necessário refazer o backup nem recarregar o banco nesse caso.
319
+
320
+ A captura antiga de Realtime pode conter valores desatualizados: a restauração reproduz os valores e a data registrados, não o estado atual do projeto original. Novos backups capturam Realtime diretamente pela API. Em backups antigos sem o sinalizador de JWT de Functions, o padrão seguro é exigir JWT; os novos backups guardam esse sinalizador.
317
321
 
318
322
  O relatório é salvo durante cada etapa, inclusive em falhas. Serviços externos não participam da transação do banco: se uma etapa posterior falhar, as anteriores permanecem registradas como concluídas. Não repita a carga do banco em um destino já preenchido. Resolva a etapa indicada e selecione apenas os componentes pendentes, ou use outro projeto vazio.
319
323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoonb",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Complete Supabase backup and migration tool. https://www.smoonb.com/#price",
5
5
  "preferGlobal": false,
6
6
  "preventGlobalInstall": true,
@@ -2,6 +2,7 @@ const chalk = require('chalk');
2
2
  const path = require('path');
3
3
  const { writeJson } = require('../../../utils/fsx');
4
4
  const { t } = require('../../../i18n');
5
+ const { authSnapshot } = require('../../../utils/auth-secrets');
5
6
 
6
7
  /**
7
8
  * Etapa 4: Backup Auth Settings via Management API
@@ -26,15 +27,18 @@ module.exports = async ({ projectId, accessToken, backupDir }) => {
26
27
 
27
28
  const authSettings = await authResponse.json();
28
29
 
29
- // Salvar configurações de Auth
30
+ const snapshot = authSnapshot(authSettings);
31
+ // Never store the opaque SMTP readback as a recoverable password.
30
32
  const authSettingsPath = path.join(backupDir, 'auth-settings.json');
31
33
  await writeJson(authSettingsPath, {
32
34
  project_id: projectId,
33
35
  timestamp: new Date().toISOString(),
34
- settings: authSettings
36
+ ...snapshot
35
37
  });
36
38
 
37
39
  console.log(chalk.green(`✅ Auth Settings exportadas: ${path.basename(authSettingsPath)}`));
40
+ if (snapshot.secrets.smtp_pass.requires_input)
41
+ console.log(chalk.yellow(getT('backup.smtp.passwordNotExported')));
38
42
  return { success: true };
39
43
 
40
44
  } catch (error) {
@@ -0,0 +1,65 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const inquirer = require('inquirer');
4
+ const { t } = require('../../i18n');
5
+ const { remapServiceUrls } = require('./dump');
6
+ const { isMasked } = require('../../utils/auth-secrets');
7
+
8
+ async function prepareAuth(
9
+ { backupPath, targetProject, remapUrls = true },
10
+ {
11
+ prompt = (questions) => inquirer.prompt(questions),
12
+ interactive = Boolean(process.stdin.isTTY),
13
+ log = console.log,
14
+ } = {},
15
+ ) {
16
+ const getT = global.smoonbI18n?.t || t;
17
+ const backup = JSON.parse(
18
+ fs.readFileSync(path.join(backupPath, 'auth-settings.json'), 'utf8'),
19
+ );
20
+ const settings =
21
+ backup.settings?.auth_url_config ||
22
+ backup.settings ||
23
+ backup.auth_url_config;
24
+ if (!settings || !Object.keys(settings).length)
25
+ throw new Error('Auth configuration missing from backup');
26
+ // Never replay an opaque value from a legacy backup as a password.
27
+ const { smtp_pass: stored, ...readable } = settings;
28
+ const desired = remapUrls
29
+ ? JSON.parse(
30
+ remapServiceUrls(
31
+ JSON.stringify(readable),
32
+ backup.project_id,
33
+ targetProject.targetProjectId,
34
+ ),
35
+ )
36
+ : readable;
37
+ const needsPassword = Boolean(
38
+ stored || settings.smtp_host || backup.secrets?.smtp_pass?.requires_input,
39
+ );
40
+ const writeOnly = {};
41
+ if (needsPassword) {
42
+ log(getT('restore.smtp.explanation'));
43
+ if (!interactive) throw new Error(getT('restore.smtp.interactiveRequired'));
44
+ const validate = (value) =>
45
+ typeof value === 'string' &&
46
+ value.trim().length > 0 &&
47
+ !isMasked(value) &&
48
+ value !== stored;
49
+ const answer = await prompt([
50
+ {
51
+ type: 'password',
52
+ name: 'smtpPassword',
53
+ mask: '*',
54
+ message: getT('restore.smtp.password'),
55
+ validate: (value) => validate(value) || getT('restore.smtp.required'),
56
+ },
57
+ ]);
58
+ if (!validate(answer.smtpPassword))
59
+ throw new Error(getT('restore.smtp.required'));
60
+ writeOnly.smtp_pass = answer.smtpPassword;
61
+ }
62
+ return { desired, writeOnly };
63
+ }
64
+
65
+ module.exports = { prepareAuth };
@@ -188,7 +188,10 @@ module.exports = async (options = {}) => {
188
188
  }
189
189
 
190
190
  if (restoreResults.authSettings) {
191
- ui.success(`🔐 ${getT('restore.index.authSettingsRestored')}`);
191
+ if (restoreResults.authSettings.verified)
192
+ ui.success(`🔐 ${getT('restore.index.authSettingsRestored')}`);
193
+ else
194
+ ui.warn(getT('restore.smtp.accepted'));
192
195
  }
193
196
 
194
197
  if (restoreResults.storage) {
@@ -1,3 +1,5 @@
1
+ const { isMasked } = require('../../utils/auth-secrets');
2
+
1
3
  async function management(
2
4
  target,
3
5
  endpoint,
@@ -33,9 +35,29 @@ async function applyConfig(
33
35
  desired,
34
36
  allowed,
35
37
  request = management,
38
+ { writeOnly = {} } = {},
36
39
  ) {
40
+ const secretFields = Object.keys(writeOnly);
41
+ for (const key of secretFields) {
42
+ if (
43
+ endpoint !== 'config/auth' ||
44
+ key !== 'smtp_pass' ||
45
+ !allowed.includes(key)
46
+ )
47
+ throw new Error(`Unsupported write-only configuration: ${key}`);
48
+ if (
49
+ typeof writeOnly[key] !== 'string' ||
50
+ !writeOnly[key].trim() ||
51
+ isMasked(writeOnly[key])
52
+ )
53
+ throw new Error(`A real credential is required: ${key}`);
54
+ }
55
+ if (endpoint === 'config/auth' && Object.hasOwn(desired, 'smtp_pass'))
56
+ throw new Error(
57
+ 'SMTP password must be supplied separately, never copied from a configuration response',
58
+ );
37
59
  const before = await request(target, endpoint);
38
- const patch = {};
60
+ const patch = { ...writeOnly };
39
61
  const unsupported = [];
40
62
  for (const [key, value] of Object.entries(desired)) {
41
63
  if (JSON.stringify(before[key]) === JSON.stringify(value)) continue;
@@ -43,10 +65,7 @@ async function applyConfig(
43
65
  unsupported.push(key);
44
66
  continue;
45
67
  }
46
- if (
47
- typeof value === 'string' &&
48
- /^(\*{3,}|[•●]{3,}|\[redacted\])$/i.test(value)
49
- )
68
+ if (isMasked(value))
50
69
  throw new Error(`Masked configuration cannot be restored: ${key}`);
51
70
  patch[key] = value;
52
71
  }
@@ -66,7 +85,10 @@ async function applyConfig(
66
85
  );
67
86
  return {
68
87
  success: true,
69
- verified: true,
88
+ verified: secretFields.length === 0,
89
+ configuration_readback_verified: true,
90
+ write_only_fields_accepted: secretFields,
91
+ ...(secretFields.length ? { email_delivery_verified: false } : {}),
70
92
  fields_verified: Object.keys(desired).length,
71
93
  fields_updated: Object.keys(patch),
72
94
  };
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const { t } = require('../../i18n');
4
4
  const { buildRestore, readDump } = require('./dump');
5
5
  const { storagePlan } = require('./steps/06-storage');
6
+ const { prepareAuth } = require('./auth');
6
7
  const steps = {
7
8
  database: require('./steps/03-database'),
8
9
  edgeFunctions: require('./steps/04-edge-functions'),
@@ -21,6 +22,7 @@ async function runRestore(
21
22
  remapUrls = true,
22
23
  },
23
24
  implementations = steps,
25
+ { prepareAuthConfig = prepareAuth } = {},
24
26
  ) {
25
27
  if (selectedBackup.projectId === targetProject.targetProjectId)
26
28
  throw new Error('Restore requires a different, empty destination project');
@@ -84,24 +86,45 @@ async function runRestore(
84
86
  buildRestore(readDump(context.backupFilePath), context);
85
87
  if (components.storage)
86
88
  storagePlan(context.backupPath, context.backupFilePath);
89
+ // Resolve write-only credentials before restoring the database or any service.
90
+ if (components.authSettings)
91
+ context.authConfig = await prepareAuthConfig(context);
87
92
  for (const [name, step] of Object.entries(implementations)) {
88
93
  if (!components[name]) continue;
89
94
  current = name;
90
95
  report.results[name] = { success: false, status: 'running' };
91
96
  save();
92
97
  const labels = {
93
- database: 'restoringDatabase', edgeFunctions: 'restoringEdgeFunctions',
94
- authSettings: 'restoringAuthSettings', storage: 'restoringStorageBuckets',
95
- databaseSettings: 'restoringDatabaseSettings', realtimeSettings: 'restoringRealtimeSettings',
98
+ database: 'restoringDatabase',
99
+ edgeFunctions: 'restoringEdgeFunctions',
100
+ authSettings: 'restoringAuthSettings',
101
+ storage: 'restoringStorageBuckets',
102
+ databaseSettings: 'restoringDatabaseSettings',
103
+ realtimeSettings: 'restoringRealtimeSettings',
96
104
  };
97
105
  console.log((global.smoonbI18n?.t || t)(`restore.index.${labels[name]}`));
98
106
  const result = await step(context);
99
- if (!result?.success || !result?.verified)
107
+ const acceptedSmtp =
108
+ name === 'authSettings' &&
109
+ result?.configuration_readback_verified === true &&
110
+ result?.email_delivery_verified === false &&
111
+ result?.write_only_fields_accepted?.length === 1 &&
112
+ result.write_only_fields_accepted[0] === 'smtp_pass';
113
+ if (!result?.success || (!result?.verified && !acceptedSmtp))
100
114
  throw new Error(`Component ${name} did not pass verification`);
101
- report.results[name] = { ...result, status: 'verified' };
115
+ report.results[name] = {
116
+ ...result,
117
+ status: result.verified
118
+ ? 'verified'
119
+ : 'applied_with_unverified_secrets',
120
+ };
102
121
  save();
122
+ if (acceptedSmtp)
123
+ console.log((global.smoonbI18n?.t || t)('restore.smtp.accepted'));
103
124
  }
104
- report.status = 'verified';
125
+ report.status = Object.values(report.results).every((r) => r.verified)
126
+ ? 'verified'
127
+ : 'applied_with_unverified_secrets';
105
128
  report.completed_at = new Date().toISOString();
106
129
  save();
107
130
  return report.results;
@@ -1,26 +1,15 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
1
  const { applyConfig } = require('../management');
4
- const { remapServiceUrls } = require('../dump');
2
+ const { prepareAuth } = require('../auth');
5
3
  const allowed = require('../auth-fields.json');
6
- module.exports = async ({ backupPath, targetProject, remapUrls = true }) => {
7
- const backup = JSON.parse(
8
- fs.readFileSync(path.join(backupPath, 'auth-settings.json'), 'utf8'),
4
+ module.exports = async (context, { request } = {}) => {
5
+ const { desired, writeOnly } =
6
+ context.authConfig || (await prepareAuth(context));
7
+ return applyConfig(
8
+ context.targetProject,
9
+ 'config/auth',
10
+ desired,
11
+ allowed,
12
+ request,
13
+ { writeOnly },
9
14
  );
10
- const settings =
11
- backup.settings?.auth_url_config ||
12
- backup.settings ||
13
- backup.auth_url_config;
14
- if (!settings || !Object.keys(settings).length)
15
- throw new Error('Auth configuration missing from backup');
16
- const desired = remapUrls
17
- ? JSON.parse(
18
- remapServiceUrls(
19
- JSON.stringify(settings),
20
- backup.project_id,
21
- targetProject.targetProjectId,
22
- ),
23
- )
24
- : settings;
25
- return applyConfig(targetProject, 'config/auth', desired, allowed);
26
15
  };
@@ -18,6 +18,12 @@
18
18
  "prompt.confirmYes": "(Y/n)",
19
19
  "prompt.confirmNo": "(y/N)",
20
20
  "prompt.cancelled": "Operation cancelled.",
21
+ "backup.smtp.passwordNotExported": "⚠️ Supabase does not export the SMTP password. Keep it in your password manager: restoring Auth will require it. You do not need to create another backup.",
22
+ "restore.smtp.explanation": "The backup contains SMTP settings, but Supabase does not let you retrieve the password. Enter your email provider password (or the key used as the SMTP password). It will be sent only to the destination Supabase project and will not be saved by smoonb.",
23
+ "restore.smtp.password": "Email provider SMTP password:",
24
+ "restore.smtp.required": "Enter the real provider password, not the protected value in auth-settings.json. Use Ctrl+C to cancel before restoring any components.",
25
+ "restore.smtp.interactiveRequired": "Restoring Auth requires the SMTP password. Run in an interactive terminal to enter it securely before any components are restored.",
26
+ "restore.smtp.accepted": "⚠️ Auth: settings checked; SMTP password accepted by the API. Supabase does not allow password readback verification. Test an authentication email before using the project.",
21
27
 
22
28
  "error.generic": "Something went wrong.",
23
29
  "error.pathNotFound": "Path not found: {path}",
@@ -18,6 +18,12 @@
18
18
  "prompt.confirmYes": "(S/n)",
19
19
  "prompt.confirmNo": "(s/N)",
20
20
  "prompt.cancelled": "Operação cancelada.",
21
+ "backup.smtp.passwordNotExported": "⚠️ O Supabase não exporta a senha SMTP. Guarde-a no seu gerenciador de senhas: ela será solicitada ao restaurar Auth. Não é necessário refazer o backup.",
22
+ "restore.smtp.explanation": "O backup contém as configurações SMTP, mas o Supabase não permite recuperar a senha. Informe a senha do provedor de e-mail (ou a chave usada como senha SMTP). Ela será enviada somente ao Supabase de destino e não será gravada pelo smoonb.",
23
+ "restore.smtp.password": "Senha SMTP do provedor de e-mail:",
24
+ "restore.smtp.required": "Informe a senha real do provedor, não o valor protegido de auth-settings.json. Use Ctrl+C para cancelar antes de restaurar os componentes.",
25
+ "restore.smtp.interactiveRequired": "A restauração de Auth precisa da senha SMTP. Execute em um terminal interativo para informá-la de forma oculta antes de restaurar os componentes.",
26
+ "restore.smtp.accepted": "⚠️ Auth: configurações conferidas; senha SMTP aceita pela API. O Supabase não permite conferir a senha por leitura. Teste o envio de e-mail de autenticação antes de usar o projeto.",
21
27
 
22
28
  "error.generic": "Algo inesperado aconteceu.",
23
29
  "error.pathNotFound": "Caminho não encontrado: {path}",
@@ -0,0 +1,23 @@
1
+ // Supabase SMTP passwords are write-only. The GET response is not a
2
+ // transferable credential, even when it looks like an ordinary string.
3
+ // https://github.com/supabase/supabase/blob/master/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx
4
+ function authSnapshot(settings) {
5
+ const { smtp_pass: smtpPass, ...readable } = settings;
6
+ return {
7
+ settings: readable,
8
+ secrets: {
9
+ smtp_pass: {
10
+ exportable: false,
11
+ requires_input: Boolean(smtpPass || settings.smtp_host),
12
+ },
13
+ },
14
+ };
15
+ }
16
+
17
+ function isMasked(value) {
18
+ return (
19
+ typeof value === 'string' && /^(\*{3,}|[•●]{3,}|\[redacted\])$/i.test(value)
20
+ );
21
+ }
22
+
23
+ module.exports = { authSnapshot, isMasked };