@adonis-agora/authkit-server 0.70.0 → 0.72.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.
@@ -153,6 +153,24 @@
153
153
  "options": { "startApp": true },
154
154
  "filePath": "import_users.js"
155
155
  },
156
+ {
157
+ "commandName": "authkit:users:normalize-emails",
158
+ "description": "Migra os e-mails gravados para a forma normalizada da identidade (trim + lowercase). Sem --apply apenas relata.",
159
+ "namespace": "authkit",
160
+ "aliases": [],
161
+ "flags": [
162
+ {
163
+ "name": "apply",
164
+ "flagName": "apply",
165
+ "required": false,
166
+ "type": "boolean",
167
+ "description": "Grava a normalização (sem ele, apenas relata)."
168
+ }
169
+ ],
170
+ "args": [],
171
+ "options": { "startApp": true },
172
+ "filePath": "normalize_emails.js"
173
+ },
156
174
  {
157
175
  "commandName": "authkit:settings:list",
158
176
  "description": "Lista todas as runtime settings presentes em `auth_settings`.",
@@ -53,12 +53,7 @@ export default class AuthkitImportUsers extends BaseCommand {
53
53
  return;
54
54
  }
55
55
  const { records, parseErrors } = parseImportFile(content);
56
- const report = await importUsers(store, records, {
57
- dryRun: this.dryRun,
58
- // Ponte legada do config (default ligada) — a checagem de duplicado
59
- // enxerga as contas gravadas com o endereço mutilado pelo cadastro antigo.
60
- legacyFallback: authkitConfig?.login?.legacyEmailFallback ?? true,
61
- });
56
+ const report = await importUsers(store, records, { dryRun: this.dryRun });
62
57
  // Erros de parsing entram no relatório agregado.
63
58
  report.errors.unshift(...parseErrors);
64
59
  if (this.dryRun) {
@@ -0,0 +1,21 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ import type { CommandOptions } from '@adonisjs/core/types/ace';
3
+ /**
4
+ * Migra os endereços GRAVADOS para a forma normalizada da identidade (`trim` +
5
+ * `toLowerCase`) — a MESMA que o cadastro grava e que o login busca.
6
+ *
7
+ * É PASSO OBRIGATÓRIO do upgrade para quem tem contas criadas antes da v0.69:
8
+ * o cadastro antigo gravava o endereço mutilado (`.normalizeEmail()` do VineJS
9
+ * removia pontos e `+tag` no gmail) e import/convite/provider social gravavam a
10
+ * grafia crua, com maiúsculas. O login busca UMA forma só; qualquer outra grafia
11
+ * gravada deixa a conta INALCANÇÁVEL, sem mensagem de erro (a tela é à prova de
12
+ * enumeração).
13
+ */
14
+ export default class AuthkitNormalizeEmails extends BaseCommand {
15
+ static commandName: string;
16
+ static description: string;
17
+ static help: string[];
18
+ static options: CommandOptions;
19
+ apply?: boolean;
20
+ run(): Promise<void>;
21
+ }
@@ -0,0 +1,106 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { BaseCommand, flags } from '@adonisjs/core/ace';
8
+ import { normalizeAccountEmails } from '../src/commands/normalize_emails.js';
9
+ import { resolveAuthkitConfig } from '../src/commands/resolve_config.js';
10
+ /**
11
+ * Migra os endereços GRAVADOS para a forma normalizada da identidade (`trim` +
12
+ * `toLowerCase`) — a MESMA que o cadastro grava e que o login busca.
13
+ *
14
+ * É PASSO OBRIGATÓRIO do upgrade para quem tem contas criadas antes da v0.69:
15
+ * o cadastro antigo gravava o endereço mutilado (`.normalizeEmail()` do VineJS
16
+ * removia pontos e `+tag` no gmail) e import/convite/provider social gravavam a
17
+ * grafia crua, com maiúsculas. O login busca UMA forma só; qualquer outra grafia
18
+ * gravada deixa a conta INALCANÇÁVEL, sem mensagem de erro (a tela é à prova de
19
+ * enumeração).
20
+ */
21
+ export default class AuthkitNormalizeEmails extends BaseCommand {
22
+ static commandName = 'authkit:users:normalize-emails';
23
+ static description = 'Migra os e-mails gravados para a forma normalizada da identidade (trim + lowercase). Sem --apply apenas relata.';
24
+ static help = [
25
+ 'Sem --apply o comando NÃO escreve nada: varre as contas e relata quantas mudariam,',
26
+ 'quais, e as COLISÕES (duas contas que colapsam no mesmo endereço).',
27
+ 'Com --apply grava, recusando-se a tocar em qualquer conta envolvida numa colisão —',
28
+ 'a migração nunca funde contas nem escolhe vencedor: as colididas saem listadas para',
29
+ 'decisão humana.',
30
+ '',
31
+ 'Sai com código != 0 quando há colisões (ou erro de escrita): é a sua deixa de que',
32
+ 'a base ainda precisa de decisão humana.',
33
+ '',
34
+ 'Exemplos:',
35
+ ' node ace authkit:users:normalize-emails',
36
+ ' node ace authkit:users:normalize-emails --apply',
37
+ ];
38
+ static options = { startApp: true };
39
+ async run() {
40
+ const config = await this.app.container.make('config');
41
+ // Resolve o config provider exportado por defineConfig (provider cru não tem accountStore).
42
+ const authkitConfig = await resolveAuthkitConfig(this.app, config.get('authkit', null));
43
+ const store = authkitConfig?.accountStore;
44
+ if (!store) {
45
+ this.logger.logError("❌ config('authkit').accountStore ausente.");
46
+ this.exitCode = 1;
47
+ return;
48
+ }
49
+ let report;
50
+ try {
51
+ report = await normalizeAccountEmails(store, { apply: this.apply });
52
+ }
53
+ catch (error) {
54
+ this.logger.logError(`❌ ${error.message}`);
55
+ this.exitCode = 1;
56
+ return;
57
+ }
58
+ if (!this.apply) {
59
+ this.logger.info('🧪 Relatório — nenhum dado foi alterado (use --apply para gravar).');
60
+ }
61
+ this.logger.info(`🔎 ${report.scanned} conta(s) varrida(s); ${report.alreadyNormalized} já normalizada(s).`);
62
+ if (report.changes.length === 0) {
63
+ this.logger.success('✅ Nenhum endereço a normalizar.');
64
+ }
65
+ else if (this.apply) {
66
+ this.logger.success(`✅ ${report.applied} endereço(s) normalizado(s).`);
67
+ }
68
+ else {
69
+ this.logger.warning(`⚠️ ${report.changes.length} endereço(s) seriam normalizado(s):`);
70
+ }
71
+ for (const change of report.changes) {
72
+ if (change.error)
73
+ continue;
74
+ this.logger.info(` ${change.from} → ${change.to}`);
75
+ }
76
+ const failures = report.changes.filter((change) => !!change.error);
77
+ if (failures.length > 0) {
78
+ this.logger.logError(`❌ ${failures.length} não gravada(s):`);
79
+ for (const change of failures) {
80
+ this.logger.logError(` ${change.from} → ${change.to}: ${change.error}`);
81
+ }
82
+ this.exitCode = 1;
83
+ }
84
+ if (report.unusable.length > 0) {
85
+ // Não entram em "já normalizada": o relatório não dá por boa uma linha que
86
+ // deixou para trás.
87
+ this.logger.warning(`⚠️ ${report.unusable.length} conta(s) com e-mail vazio/inutilizável — NÃO migrada(s):`);
88
+ for (const account of report.unusable) {
89
+ this.logger.warning(` id ${account.accountId}: ${JSON.stringify(account.email)}`);
90
+ }
91
+ }
92
+ if (report.collisions.length > 0) {
93
+ this.logger.logError(`❌ ${report.collisions.length} colisão(ões) — ${report.skippedByCollision} conta(s) NÃO tocada(s). Decida à mão (a migração não funde contas):`);
94
+ for (const collision of report.collisions) {
95
+ this.logger.logError(` ${collision.email}:`);
96
+ for (const account of collision.accounts) {
97
+ this.logger.logError(` - ${account.email} (id ${account.accountId})`);
98
+ }
99
+ }
100
+ this.exitCode = 1;
101
+ }
102
+ }
103
+ }
104
+ __decorate([
105
+ flags.boolean({ description: 'Grava a normalização (sem ele, apenas relata).' })
106
+ ], AuthkitNormalizeEmails.prototype, "apply", void 0);
@@ -17,8 +17,11 @@
17
17
  @end
18
18
 
19
19
  {{-- Step-up sem MFA enrolado: bloqueia o login e instrui a configurar o MFA;
20
- não renderiza o campo de código (não há 2º fator a desafiar). --}}
21
- @if(!noEnrollment)
20
+ não renderiza o campo de código (não há 2º fator a desafiar).
21
+ `totpAvailable === false` = a conta só tem passkey, sem app autenticador
22
+ confirmado: pedir um código de 6 dígitos seria pedir o impossível. Ausente
23
+ (stores que não reportam o estado) mantém o campo visível. --}}
24
+ @if(!noEnrollment && totpAvailable !== false)
22
25
  <div class="mt-6">
23
26
  <label for="code" class="mb-1 block text-sm font-medium text-gray-700">{{ t('mfa_challenge.code_label') }}</label>
24
27
  <input id="code" name="code" inputmode="numeric" autocomplete="one-time-code"
@@ -56,7 +59,9 @@
56
59
  <p id="passkey-error" class="mt-3 hidden text-sm text-red-600">{{ t('mfa_challenge.passkey_error') }}</p>
57
60
  @end
58
61
 
59
- @if(!noEnrollment)
62
+ {{-- Códigos de recuperação só existem junto com o TOTP (são gerados no
63
+ enrollment); sem ele, a seção não tem o que receber. --}}
64
+ @if(!noEnrollment && totpAvailable !== false)
60
65
  <details class="mt-6 text-sm text-gray-600">
61
66
  <summary class="cursor-pointer hover:underline">{{ t('mfa_challenge.recovery_summary') }}</summary>
62
67
  <form method="POST" action="/auth/interaction/{{ uid }}/mfa" class="mt-3">
package/build/index.d.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  * O comando do AdonisJS importa o entrypoint principal e procura por estes exports.
4
4
  */
5
5
  export { configure } from './commands/configure.js';
6
- export type { AccountDeletionCapability, AccountImportCapability, AccountSecurityCapability, AccountStatusCapability, AccountStore, ActiveOrgInfo, AdminCapability, AuthAccount, CoreAccountStore, CreateAccountInput, EmailVerificationStatusCapability, ImportAccountInput, LinkProviderIdentityInput, ListAccountsParams, LoginMethodsPreferenceCapability, MagicLinkCapability, MfaCapability, OrganizationsCapability, OrgInvitation, OrgMember, OrgSummary, OtpLoginCapability, OtpLoginVerifyResult, Paginated, PasskeySummary, ProfileCapability, ProviderIdentityCapability, ProviderIdentitySummary, WebauthnCapability, } from './src/accounts/account_store.js';
7
- export { supportsAccountDeletion, supportsAccountImport, supportsAccountSecurity, supportsAccountStatus, supportsEmailVerificationStatus, supportsLoginMethodsPreference, supportsMagicLink, supportsMfa, supportsOrganizations, supportsOtpLogin, supportsPasskeys, supportsProfile, supportsProviderIdentity, } from './src/accounts/account_store.js';
6
+ export type { AccountDeletionCapability, AccountEmailRewriteCapability, AccountImportCapability, AccountSecurityCapability, AccountStatusCapability, AccountStore, ActiveOrgInfo, AdminCapability, AuthAccount, CoreAccountStore, CreateAccountInput, EmailVerificationStatusCapability, ImportAccountInput, LinkProviderIdentityInput, ListAccountsParams, LoginMethodsPreferenceCapability, MagicLinkCapability, MfaCapability, OrganizationsCapability, OrgInvitation, OrgMember, OrgSummary, OtpLoginCapability, OtpLoginVerifyResult, Paginated, PasskeySummary, ProfileCapability, ProviderIdentityCapability, ProviderIdentitySummary, WebauthnCapability, } from './src/accounts/account_store.js';
7
+ export { supportsAccountDeletion, supportsAccountEmailRewrite, supportsAccountImport, supportsAccountSecurity, supportsAccountStatus, supportsEmailVerificationStatus, supportsLoginMethodsPreference, supportsMagicLink, supportsMfa, supportsOrganizations, supportsOtpLogin, supportsPasskeys, supportsProfile, supportsProviderIdentity, } from './src/accounts/account_store.js';
8
8
  export type { AccountSecretEncrypter, LucidAccountStoreOptions, } from './src/accounts/lucid_account_store.js';
9
9
  export { appKeyEncrypter, lucidAccountStore } from './src/accounts/lucid_account_store.js';
10
10
  export { AuthOrganization, AuthOrganizationInvitation, AuthOrganizationMember, defaultOrganizationModels, } from './src/accounts/lucid_store/organization_models.js';
@@ -64,8 +64,7 @@ export { deriveLockedSettingKeys, isSettingLocked, lockedSettingKeys, POLICY_ROU
64
64
  export { consoleLoginUrl, getAccountId, hasAccountSession, realAccountId, } from './src/host/console_session.js';
65
65
  export type { AuthkitCsrfOptions } from './src/host/csrf.js';
66
66
  export { authkitCsrfExceptions } from './src/host/csrf.js';
67
- export type { EmailIdentifierLookup, ResolvedEmailIdentifier, } from './src/host/email_identifier.js';
68
- export { legacyNormalizeEmailIdentifier, normalizeEmailIdentifier, resolveEmailIdentifier, } from './src/host/email_identifier.js';
67
+ export { normalizeEmailIdentifier } from './src/host/email_identifier.js';
69
68
  export type { ResolveGeo } from './src/host/geo.js';
70
69
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
71
70
  export type { AuthMessages, I18nConfig } from './src/host/i18n.js';
package/build/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * O comando do AdonisJS importa o entrypoint principal e procura por estes exports.
4
4
  */
5
5
  export { configure } from './commands/configure.js';
6
- export { supportsAccountDeletion, supportsAccountImport, supportsAccountSecurity, supportsAccountStatus, supportsEmailVerificationStatus, supportsLoginMethodsPreference, supportsMagicLink, supportsMfa, supportsOrganizations, supportsOtpLogin, supportsPasskeys, supportsProfile, supportsProviderIdentity, } from './src/accounts/account_store.js';
6
+ export { supportsAccountDeletion, supportsAccountEmailRewrite, supportsAccountImport, supportsAccountSecurity, supportsAccountStatus, supportsEmailVerificationStatus, supportsLoginMethodsPreference, supportsMagicLink, supportsMfa, supportsOrganizations, supportsOtpLogin, supportsPasskeys, supportsProfile, supportsProviderIdentity, } from './src/accounts/account_store.js';
7
7
  export { appKeyEncrypter, lucidAccountStore } from './src/accounts/lucid_account_store.js';
8
8
  export { AuthOrganization, AuthOrganizationInvitation, AuthOrganizationMember, defaultOrganizationModels, } from './src/accounts/lucid_store/organization_models.js';
9
9
  export { lucidStores } from './src/accounts/lucid_stores.js';
@@ -49,7 +49,7 @@ export { brandFor, isFirstParty, isFirstPartyClient } from './src/host/branding.
49
49
  export { deriveLockedSettingKeys, isSettingLocked, lockedSettingKeys, POLICY_ROUTE_OPTIONS, resetLockedSettingKeys, SettingLockedError, setLockedSettingKeys, } from './src/host/config_locks.js';
50
50
  export { consoleLoginUrl, getAccountId, hasAccountSession, realAccountId, } from './src/host/console_session.js';
51
51
  export { authkitCsrfExceptions } from './src/host/csrf.js';
52
- export { legacyNormalizeEmailIdentifier, normalizeEmailIdentifier, resolveEmailIdentifier, } from './src/host/email_identifier.js';
52
+ export { normalizeEmailIdentifier } from './src/host/email_identifier.js';
53
53
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
54
54
  export { BUILTIN_MESSAGES, DEFAULT_LOCALE, DEFAULT_MESSAGES, PT_BR_MESSAGES, resolveMessages, translate, } from './src/host/i18n.js';
55
55
  export { ensureConsoleSession } from './src/host/idp_session_bridge.js';
@@ -250,10 +250,18 @@ export interface MfaCapability {
250
250
  * mecanismo de "trusted devices": um cookie de confiança emitido ANTES desse
251
251
  * instante é considerado inválido (re-enrolar MFA revoga a confiança). Pode ser
252
252
  * `null`/ausente quando o MFA não está ativo ou o store não rastreia o instante.
253
+ *
254
+ * `totp` diz se há um app autenticador CONFIRMADO (segredo + códigos de
255
+ * recuperação). É diferente de `enabled`: registrar uma passkey também liga o
256
+ * MFA, e remover a última passkey não o desliga — então `enabled` sozinho não
257
+ * responde "existe um segundo fator que esta pessoa consegue apresentar?".
258
+ * Quem decide mostrar (ou não) o desafio precisa de `totp`; stores antigos que
259
+ * o omitem continuam funcionando, só não distinguem os dois casos.
253
260
  */
254
261
  getMfaState(accountId: string): Promise<{
255
262
  enabled: boolean;
256
263
  enabledAt?: number | null;
264
+ totp?: boolean;
257
265
  }>;
258
266
  /**
259
267
  * Inicia o enrollment TOTP: gera um segredo PENDENTE (mfaEnabledAt continua
@@ -441,6 +449,28 @@ export interface AccountImportCapability {
441
449
  */
442
450
  importAccount(input: ImportAccountInput): Promise<AuthAccount | null>;
443
451
  }
452
+ /**
453
+ * Regravação ADMINISTRATIVA do endereço da conta (comando
454
+ * `authkit:users:normalize-emails`). CAPACIDADE opcional, presente no store
455
+ * Lucid default.
456
+ *
457
+ * Distinta de {@link AccountSecurityCapability.requestEmailChange}: NÃO há
458
+ * cerimônia de confirmação (nenhum token viaja, nenhum e-mail é enviado) e o
459
+ * estado de "e-mail verificado" fica COMO ESTÁ — a migração só canonicaliza a
460
+ * grafia de uma caixa postal que já era a mesma (`trim` + `toLowerCase`), então
461
+ * marcar como verificado seria conceder uma prova que ninguém deu.
462
+ *
463
+ * Não é um caminho para trocar de titular: quem quer mudar de caixa postal passa
464
+ * pelo fluxo com confirmação. Um store pode simplesmente não a implementar — aí
465
+ * o `--apply` do comando recusa e a base é migrada por SQL do host.
466
+ */
467
+ export interface AccountEmailRewriteCapability {
468
+ /**
469
+ * Regrava o e-mail da conta. Retorna `false` quando a conta não existe ou
470
+ * quando o endereço já pertence a OUTRA conta (a migração nunca funde contas).
471
+ */
472
+ rewriteAccountEmail(accountId: string, email: string): Promise<boolean>;
473
+ }
444
474
  /**
445
475
  * Login sem senha por "magic link" — um token de uso único e curta duração
446
476
  * enviado por e-mail. CAPACIDADE opcional: stores sem suporte omitem os métodos e
@@ -661,7 +691,7 @@ export type AccountStore = CoreAccountStore & {
661
691
  * Undefined → `users` (back-compat com stores próprios).
662
692
  */
663
693
  readonly accountTable?: string;
664
- } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability & LoginMethodsPreferenceCapability>;
694
+ } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & AccountEmailRewriteCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability & LoginMethodsPreferenceCapability>;
665
695
  /** Type guard: o store implementa a capacidade de MFA / TOTP. */
666
696
  export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
667
697
  /**
@@ -712,6 +742,8 @@ export declare function supportsEmailVerificationStatus(store: AccountStore): st
712
742
  export declare function supportsAccountDeletion(store: AccountStore): store is AccountStore & AccountDeletionCapability;
713
743
  /** Type guard: o store implementa o import em massa de contas. */
714
744
  export declare function supportsAccountImport(store: AccountStore): store is AccountStore & AccountImportCapability;
745
+ /** Type guard: o store implementa a regravação administrativa do e-mail. */
746
+ export declare function supportsAccountEmailRewrite(store: AccountStore): store is AccountStore & AccountEmailRewriteCapability;
715
747
  /** Type guard: o store implementa histórico de senhas (disallow_password_reuse). */
716
748
  export declare function supportsPasswordHistory(store: AccountStore): store is AccountStore & PasswordHistoryCapability;
717
749
  /** Type guard: o store implementa expiração de senha (password_changed_at coluna). */
@@ -75,6 +75,10 @@ export function supportsAccountDeletion(store) {
75
75
  export function supportsAccountImport(store) {
76
76
  return typeof store.importAccount === 'function';
77
77
  }
78
+ /** Type guard: o store implementa a regravação administrativa do e-mail. */
79
+ export function supportsAccountEmailRewrite(store) {
80
+ return typeof store.rewriteAccountEmail === 'function';
81
+ }
78
82
  /** Type guard: o store implementa histórico de senhas (disallow_password_reuse). */
79
83
  export function supportsPasswordHistory(store) {
80
84
  return typeof store.isPasswordReused === 'function';
@@ -1,4 +1,4 @@
1
- import type { AccountImportCapability, AccountSecurityCapability, CoreAccountStore, MagicLinkCapability, OtpLoginCapability } from '../account_store.js';
1
+ import type { AccountEmailRewriteCapability, AccountImportCapability, AccountSecurityCapability, CoreAccountStore, MagicLinkCapability, OtpLoginCapability } from '../account_store.js';
2
2
  import type { LucidStoreContext } from './shared.js';
3
3
  /**
4
4
  * Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
@@ -6,4 +6,4 @@ import type { LucidStoreContext } from './shared.js';
6
6
  * (listagem paginada + roles globais) e o self-service de segurança
7
7
  * ({@link AccountSecurityCapability}: trocar senha/e-mail).
8
8
  */
9
- export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability & OtpLoginCapability & AccountImportCapability;
9
+ export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability & OtpLoginCapability & AccountImportCapability & AccountEmailRewriteCapability;
@@ -432,6 +432,22 @@ export function buildCore(ctx) {
432
432
  await row.save();
433
433
  return { token, account: toAccount(row), newEmail };
434
434
  },
435
+ async rewriteAccountEmail(accountId, email) {
436
+ const row = await Model.find(accountId);
437
+ if (!row)
438
+ return false;
439
+ // Nunca funde contas: se o endereço já é de OUTRA conta, recusa. O comando
440
+ // já detecta a colisão antes de chegar aqui; isto é a rede de baixo, para
441
+ // a corrida (outra conta gravada entre o relatório e a escrita).
442
+ const taken = await Model.query().where('email', email).first();
443
+ if (taken && taken.id !== row.id)
444
+ return false;
445
+ row.email = email;
446
+ // `emailVerifiedAt` fica COMO ESTÁ: a caixa postal é a mesma, só a grafia
447
+ // gravada muda — não há prova nova para registrar.
448
+ await row.save();
449
+ return true;
450
+ },
435
451
  async confirmEmailChange(token) {
436
452
  if (!token || !token.startsWith(EMAIL_CHANGE_PREFIX))
437
453
  return { ok: false };
@@ -25,7 +25,15 @@ export function buildMfa(ctx) {
25
25
  const state = await repo.read(accountId);
26
26
  // `enabledAt` (epoch ms) habilita o trusted-device check: um cookie de
27
27
  // confiança emitido ANTES deste instante é inválido (re-enrolar revoga).
28
- return { enabled: !!state?.mfaEnabledAt, enabledAt: state?.mfaEnabledAt ?? null };
28
+ // `totp` é o fator REALMENTE utilizável: segredo confirmado + códigos de
29
+ // recuperação gravados. `enabled` também liga ao registrar passkey (ver
30
+ // `webauthn.ts`) e não desliga ao remover a última — sozinho, não diz se
31
+ // sobrou algum fator para desafiar.
32
+ return {
33
+ enabled: !!state?.mfaEnabledAt,
34
+ enabledAt: state?.mfaEnabledAt ?? null,
35
+ totp: !!(state?.mfaEnabledAt && state?.totpSecret && state?.recoveryCodes),
36
+ };
29
37
  },
30
38
  async startTotpEnrollment(accountId) {
31
39
  // O email/QR vem do model principal; só o ESTADO de MFA vive em auth_mfa.
@@ -42,17 +42,10 @@ export declare function parseImportFile(content: string): {
42
42
  *
43
43
  * Lógica PURA quanto a I/O de arquivo (recebe os registros já parseados) — fácil
44
44
  * de testar. Lança se o store não suporta import nem create.
45
- *
46
- * `legacyFallback` (default ligado, como no resto da lib) faz a checagem de
47
- * duplicado enxergar também a conta que o cadastro antigo gravou com o endereço
48
- * mutilado — importar a grafia REAL do dono dela pula como duplicado em vez de
49
- * criar uma SEGUNDA conta. O comando não tem acesso ao config, então o flag
50
- * chega por aqui.
51
45
  */
52
46
  export declare function importUsers(store: AccountStore, records: {
53
47
  line: number;
54
48
  record: ImportUserRecord;
55
49
  }[], options?: {
56
50
  dryRun?: boolean;
57
- legacyFallback?: boolean;
58
51
  }): Promise<ImportReport>;
@@ -1,5 +1,5 @@
1
1
  import { supportsAccountImport } from '../accounts/account_store.js';
2
- import { normalizeEmailIdentifier, resolveEmailIdentifier } from '../host/email_identifier.js';
2
+ import { normalizeEmailIdentifier } from '../host/email_identifier.js';
3
3
  /**
4
4
  * Faz o parse do conteúdo do arquivo: aceita NDJSON (uma linha JSON por usuário)
5
5
  * OU um array JSON. Retorna os registros + os erros de parsing por linha. PURO
@@ -48,12 +48,6 @@ export function parseImportFile(content) {
48
48
  *
49
49
  * Lógica PURA quanto a I/O de arquivo (recebe os registros já parseados) — fácil
50
50
  * de testar. Lança se o store não suporta import nem create.
51
- *
52
- * `legacyFallback` (default ligado, como no resto da lib) faz a checagem de
53
- * duplicado enxergar também a conta que o cadastro antigo gravou com o endereço
54
- * mutilado — importar a grafia REAL do dono dela pula como duplicado em vez de
55
- * criar uma SEGUNDA conta. O comando não tem acesso ao config, então o flag
56
- * chega por aqui.
57
51
  */
58
52
  export async function importUsers(store, records, options = {}) {
59
53
  const report = { created: 0, skippedDuplicate: 0, errors: [] };
@@ -66,11 +60,10 @@ export async function importUsers(store, records, options = {}) {
66
60
  report.errors.push({ line, reason: 'missing email' });
67
61
  continue;
68
62
  }
69
- // Duplicado: e-mail já existe → pula. Enxerga também a conta gravada com o
70
- // endereço mutilado (ponte legada) — empate segue criando, como no cadastro.
71
- const existing = (await resolveEmailIdentifier(store, record.email, {
72
- legacyFallback: options.legacyFallback,
73
- })).account;
63
+ // Duplicado pela MESMA forma que o login busca — a normalizada. Uma base com
64
+ // endereços gravados em outra grafia pede `authkit:users:normalize-emails`
65
+ // ANTES do import, senão o duplicado passa despercebido.
66
+ const existing = await store.findByEmail(email);
74
67
  if (existing) {
75
68
  report.skippedDuplicate++;
76
69
  continue;
@@ -0,0 +1,78 @@
1
+ import type { AccountStore } from '../accounts/account_store.js';
2
+ /** Uma conta cujo endereço gravado difere da forma normalizada. */
3
+ export interface EmailNormalizationChange {
4
+ accountId: string;
5
+ /** Endereço como está gravado hoje. */
6
+ from: string;
7
+ /** Forma normalizada (`trim` + `toLowerCase`) — o que o login busca. */
8
+ to: string;
9
+ /** `true` quando o `--apply` gravou; `false` no relatório e quando a escrita falhou. */
10
+ applied: boolean;
11
+ /** Motivo da escrita não ter acontecido (só com `--apply`). */
12
+ error?: string;
13
+ }
14
+ /**
15
+ * Duas ou mais contas que colapsam no MESMO endereço normalizado — por exemplo
16
+ * `Davi@Acme.com` e `davi@acme.com`. A migração NÃO escolhe vencedor e NÃO funde
17
+ * contas: lista as envolvidas e não toca em nenhuma delas.
18
+ */
19
+ export interface EmailNormalizationCollision {
20
+ /** A forma normalizada em que as contas colidem. */
21
+ email: string;
22
+ /** As contas envolvidas, com o endereço que cada uma tem gravado hoje. */
23
+ accounts: {
24
+ accountId: string;
25
+ email: string;
26
+ }[];
27
+ }
28
+ export interface NormalizeEmailsReport {
29
+ /** Contas varridas. */
30
+ scanned: number;
31
+ /** Contas que já estão na forma normalizada (não são tocadas). */
32
+ alreadyNormalized: number;
33
+ /** As que mudariam (ou mudaram, com `--apply`), fora de colisão. */
34
+ changes: EmailNormalizationChange[];
35
+ /** Quantas foram efetivamente gravadas (0 sem `--apply`). */
36
+ applied: number;
37
+ /** Colisões encontradas — decisão humana. */
38
+ collisions: EmailNormalizationCollision[];
39
+ /** Contas que a colisão impediu de mexer (soma das contas de `collisions`). */
40
+ skippedByCollision: number;
41
+ /**
42
+ * Contas cujo endereço gravado NÃO normaliza para nada (coluna nula, vazia ou
43
+ * só espaços). Não entram em `alreadyNormalized`: gravar string vazia como
44
+ * identidade seria pior que deixar como está, e um operador que lê "já
45
+ * normalizada" não pode achar que estas linhas estão bem.
46
+ */
47
+ unusable: {
48
+ accountId: string;
49
+ email: string;
50
+ }[];
51
+ }
52
+ /**
53
+ * Migração dos endereços GRAVADOS para a forma normalizada da identidade
54
+ * (`normalizeEmailIdentifier`: `trim` + `toLowerCase`).
55
+ *
56
+ * POR QUE ELA EXISTE: até a v0.68 o cadastro gravava o endereço mutilado pelo
57
+ * `.normalizeEmail()` do VineJS (no gmail, sem pontos e sem `+tag`), e import,
58
+ * convite e provider social gravavam a grafia crua, com maiúsculas. O login
59
+ * busca UMA forma só — a normalizada. Uma conta gravada em qualquer outra grafia
60
+ * fica INALCANÇÁVEL, e, por o login ser à prova de enumeração, sem nenhuma
61
+ * mensagem de erro. Esta migração é o que reencontra essas contas.
62
+ *
63
+ * Em modo RELATÓRIO (default) não escreve nada: varre, calcula e devolve o que
64
+ * mudaria. Com `apply`, grava — RECUSANDO-SE a tocar em qualquer conta envolvida
65
+ * numa colisão (duas contas que colapsam no mesmo endereço). Nunca funde contas,
66
+ * nunca escolhe vencedor: as colididas saem listadas para decisão humana.
67
+ *
68
+ * A varredura acontece INTEIRA antes de qualquer escrita, de propósito: a
69
+ * listagem é ordenada por e-mail, e reescrever endereços no meio da paginação
70
+ * moveria linhas entre páginas — contas seriam puladas sem nenhum sinal.
71
+ *
72
+ * Lógica PURA quanto a CLI (recebe o store, devolve o relatório) — testável sem
73
+ * ace e sem banco.
74
+ */
75
+ export declare function normalizeAccountEmails(store: AccountStore, options?: {
76
+ apply?: boolean;
77
+ pageSize?: number;
78
+ }): Promise<NormalizeEmailsReport>;
@@ -0,0 +1,129 @@
1
+ import { supportsAccountEmailRewrite } from '../accounts/account_store.js';
2
+ import { normalizeEmailIdentifier } from '../host/email_identifier.js';
3
+ import { ADMIN_LIST_DEFAULT_SIZE } from '../pagination.js';
4
+ /** Comparação por code unit — mesma ordem em qualquer máquina, sem depender do ICU. */
5
+ function byCodeUnit(a, b) {
6
+ if (a === b)
7
+ return 0;
8
+ return a < b ? -1 : 1;
9
+ }
10
+ /**
11
+ * Migração dos endereços GRAVADOS para a forma normalizada da identidade
12
+ * (`normalizeEmailIdentifier`: `trim` + `toLowerCase`).
13
+ *
14
+ * POR QUE ELA EXISTE: até a v0.68 o cadastro gravava o endereço mutilado pelo
15
+ * `.normalizeEmail()` do VineJS (no gmail, sem pontos e sem `+tag`), e import,
16
+ * convite e provider social gravavam a grafia crua, com maiúsculas. O login
17
+ * busca UMA forma só — a normalizada. Uma conta gravada em qualquer outra grafia
18
+ * fica INALCANÇÁVEL, e, por o login ser à prova de enumeração, sem nenhuma
19
+ * mensagem de erro. Esta migração é o que reencontra essas contas.
20
+ *
21
+ * Em modo RELATÓRIO (default) não escreve nada: varre, calcula e devolve o que
22
+ * mudaria. Com `apply`, grava — RECUSANDO-SE a tocar em qualquer conta envolvida
23
+ * numa colisão (duas contas que colapsam no mesmo endereço). Nunca funde contas,
24
+ * nunca escolhe vencedor: as colididas saem listadas para decisão humana.
25
+ *
26
+ * A varredura acontece INTEIRA antes de qualquer escrita, de propósito: a
27
+ * listagem é ordenada por e-mail, e reescrever endereços no meio da paginação
28
+ * moveria linhas entre páginas — contas seriam puladas sem nenhum sinal.
29
+ *
30
+ * Lógica PURA quanto a CLI (recebe o store, devolve o relatório) — testável sem
31
+ * ace e sem banco.
32
+ */
33
+ export async function normalizeAccountEmails(store, options = {}) {
34
+ const pageSize = options.pageSize ?? ADMIN_LIST_DEFAULT_SIZE;
35
+ const report = {
36
+ scanned: 0,
37
+ alreadyNormalized: 0,
38
+ changes: [],
39
+ applied: 0,
40
+ collisions: [],
41
+ skippedByCollision: 0,
42
+ unusable: [],
43
+ };
44
+ // 1) Varredura completa. Guarda só id + e-mail (strings), agrupados pela forma
45
+ // normalizada — é o agrupamento que revela as colisões.
46
+ const buckets = new Map();
47
+ for (let page = 1;; page++) {
48
+ const { data, total } = await store.listAccounts({ page, size: pageSize });
49
+ if (!data.length)
50
+ break;
51
+ for (const account of data) {
52
+ report.scanned++;
53
+ const normalized = normalizeEmailIdentifier(account.email);
54
+ const bucket = buckets.get(normalized);
55
+ if (bucket)
56
+ bucket.push({ accountId: account.id, email: account.email });
57
+ else
58
+ buckets.set(normalized, [{ accountId: account.id, email: account.email }]);
59
+ }
60
+ if (report.scanned >= total)
61
+ break;
62
+ }
63
+ // 2) Classificação: colisão, mudança ou já normalizada.
64
+ const pending = [];
65
+ for (const [normalized, accounts] of buckets) {
66
+ // `normalized` vazio (coluna nula/vazia/só espaços) não é migrável. Sai
67
+ // LISTADO, não somado às "já normalizadas": o relatório não pode dar
68
+ // atestado de saúde para a linha que ele deliberadamente deixou para trás.
69
+ // ANTES da checagem de colisão: duas linhas em branco caem no mesmo balde
70
+ // `""` e sairiam como "colisão no endereço ''", que não descreve nada.
71
+ if (!normalized) {
72
+ report.unusable.push(...accounts);
73
+ continue;
74
+ }
75
+ if (accounts.length > 1) {
76
+ // Duas contas distintas no mesmo endereço normalizado. NENHUMA é tocada —
77
+ // nem a que já está normalizada, porque a decisão (fundir? renomear? qual
78
+ // delas é a pessoa?) é humana e envolve as duas.
79
+ report.collisions.push({ email: normalized, accounts });
80
+ report.skippedByCollision += accounts.length;
81
+ continue;
82
+ }
83
+ const [account] = accounts;
84
+ if (account.email === normalized) {
85
+ report.alreadyNormalized++;
86
+ continue;
87
+ }
88
+ pending.push({
89
+ accountId: account.accountId,
90
+ from: account.email,
91
+ to: normalized,
92
+ applied: false,
93
+ });
94
+ }
95
+ // Ordem estável (por endereço gravado) para o relatório ser diffável entre
96
+ // runs. Comparação por code unit, NÃO `localeCompare`: a ordem desta depende
97
+ // do ICU da máquina, e aí "diffável" valeria só dentro de um host.
98
+ pending.sort((a, b) => byCodeUnit(a.from, b.from));
99
+ report.collisions.sort((a, b) => byCodeUnit(a.email, b.email));
100
+ // `unusable` também: sem isto a lista sairia na ordem da varredura, que é a
101
+ // ordem do store — a mesma garantia não valeria para ela.
102
+ report.unusable.sort((a, b) => byCodeUnit(a.accountId, b.accountId));
103
+ report.changes = pending;
104
+ if (!options.apply)
105
+ return report;
106
+ // 3) Escrita. Capacidade probada: um store sem ela não tem como regravar o
107
+ // endereço, e inventar um caminho por fora do store não é da lib.
108
+ if (!supportsAccountEmailRewrite(store)) {
109
+ throw new Error('accountStore não implementa rewriteAccountEmail: a migração não tem como gravar (relatório segue funcionando).');
110
+ }
111
+ for (const change of pending) {
112
+ try {
113
+ const ok = await store.rewriteAccountEmail(change.accountId, change.to);
114
+ if (ok) {
115
+ change.applied = true;
116
+ report.applied++;
117
+ }
118
+ else {
119
+ // O store recusou: conta sumiu ou o endereço passou a ser de OUTRA conta
120
+ // entre a varredura e a escrita (colisão que nasceu no meio do caminho).
121
+ change.error = 'o store recusou a regravação (conta inexistente ou endereço já tomado)';
122
+ }
123
+ }
124
+ catch (error) {
125
+ change.error = error.message;
126
+ }
127
+ }
128
+ return report;
129
+ }