@adonis-agora/authkit-server 0.70.0 → 0.71.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/build/commands/commands.json +18 -0
- package/build/commands/import_users.js +1 -6
- package/build/commands/normalize_emails.d.ts +21 -0
- package/build/commands/normalize_emails.js +106 -0
- package/build/index.d.ts +3 -4
- package/build/index.js +2 -2
- package/build/src/accounts/account_store.d.ts +25 -1
- package/build/src/accounts/account_store.js +4 -0
- package/build/src/accounts/lucid_store/core.d.ts +2 -2
- package/build/src/accounts/lucid_store/core.js +16 -0
- package/build/src/commands/import_users.d.ts +0 -7
- package/build/src/commands/import_users.js +5 -12
- package/build/src/commands/normalize_emails.d.ts +78 -0
- package/build/src/commands/normalize_emails.js +129 -0
- package/build/src/define_config.d.ts +0 -16
- package/build/src/define_config.js +0 -1
- package/build/src/host/admin_api/admin_users_service.js +5 -7
- package/build/src/host/controllers/account_session_controller.js +6 -11
- package/build/src/host/controllers/interaction_controller.d.ts +5 -5
- package/build/src/host/controllers/interaction_controller.js +35 -62
- package/build/src/host/controllers/registration_controller.js +11 -27
- package/build/src/host/controllers/social_controller.js +5 -7
- package/build/src/host/email_identifier.d.ts +7 -70
- package/build/src/host/email_identifier.js +7 -210
- package/package.json +1 -1
|
@@ -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);
|
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
|
|
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 {
|
|
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';
|
|
@@ -441,6 +441,28 @@ export interface AccountImportCapability {
|
|
|
441
441
|
*/
|
|
442
442
|
importAccount(input: ImportAccountInput): Promise<AuthAccount | null>;
|
|
443
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Regravação ADMINISTRATIVA do endereço da conta (comando
|
|
446
|
+
* `authkit:users:normalize-emails`). CAPACIDADE opcional, presente no store
|
|
447
|
+
* Lucid default.
|
|
448
|
+
*
|
|
449
|
+
* Distinta de {@link AccountSecurityCapability.requestEmailChange}: NÃO há
|
|
450
|
+
* cerimônia de confirmação (nenhum token viaja, nenhum e-mail é enviado) e o
|
|
451
|
+
* estado de "e-mail verificado" fica COMO ESTÁ — a migração só canonicaliza a
|
|
452
|
+
* grafia de uma caixa postal que já era a mesma (`trim` + `toLowerCase`), então
|
|
453
|
+
* marcar como verificado seria conceder uma prova que ninguém deu.
|
|
454
|
+
*
|
|
455
|
+
* Não é um caminho para trocar de titular: quem quer mudar de caixa postal passa
|
|
456
|
+
* pelo fluxo com confirmação. Um store pode simplesmente não a implementar — aí
|
|
457
|
+
* o `--apply` do comando recusa e a base é migrada por SQL do host.
|
|
458
|
+
*/
|
|
459
|
+
export interface AccountEmailRewriteCapability {
|
|
460
|
+
/**
|
|
461
|
+
* Regrava o e-mail da conta. Retorna `false` quando a conta não existe ou
|
|
462
|
+
* quando o endereço já pertence a OUTRA conta (a migração nunca funde contas).
|
|
463
|
+
*/
|
|
464
|
+
rewriteAccountEmail(accountId: string, email: string): Promise<boolean>;
|
|
465
|
+
}
|
|
444
466
|
/**
|
|
445
467
|
* Login sem senha por "magic link" — um token de uso único e curta duração
|
|
446
468
|
* enviado por e-mail. CAPACIDADE opcional: stores sem suporte omitem os métodos e
|
|
@@ -661,7 +683,7 @@ export type AccountStore = CoreAccountStore & {
|
|
|
661
683
|
* Undefined → `users` (back-compat com stores próprios).
|
|
662
684
|
*/
|
|
663
685
|
readonly accountTable?: string;
|
|
664
|
-
} & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability & LoginMethodsPreferenceCapability>;
|
|
686
|
+
} & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & AccountEmailRewriteCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability & LoginMethodsPreferenceCapability>;
|
|
665
687
|
/** Type guard: o store implementa a capacidade de MFA / TOTP. */
|
|
666
688
|
export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
|
|
667
689
|
/**
|
|
@@ -712,6 +734,8 @@ export declare function supportsEmailVerificationStatus(store: AccountStore): st
|
|
|
712
734
|
export declare function supportsAccountDeletion(store: AccountStore): store is AccountStore & AccountDeletionCapability;
|
|
713
735
|
/** Type guard: o store implementa o import em massa de contas. */
|
|
714
736
|
export declare function supportsAccountImport(store: AccountStore): store is AccountStore & AccountImportCapability;
|
|
737
|
+
/** Type guard: o store implementa a regravação administrativa do e-mail. */
|
|
738
|
+
export declare function supportsAccountEmailRewrite(store: AccountStore): store is AccountStore & AccountEmailRewriteCapability;
|
|
715
739
|
/** Type guard: o store implementa histórico de senhas (disallow_password_reuse). */
|
|
716
740
|
export declare function supportsPasswordHistory(store: AccountStore): store is AccountStore & PasswordHistoryCapability;
|
|
717
741
|
/** 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 };
|
|
@@ -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
|
|
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
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
|
@@ -537,26 +537,10 @@ export interface LoginConfigInput {
|
|
|
537
537
|
* idêntico ao de antes, e-mail idêntico). Ver `host/otp_login.ts`.
|
|
538
538
|
*/
|
|
539
539
|
otp?: OtpLoginConfigInput;
|
|
540
|
-
/**
|
|
541
|
-
* PONTE TEMPORÁRIA: no passo de identificador, quando o e-mail normalizado
|
|
542
|
-
* (`trim` + `toLowerCase`) não acha conta nenhuma, tenta também o endereço
|
|
543
|
-
* exatamente como foi digitado e a normalização LEGADA (os defaults do
|
|
544
|
-
* validator.js que o cadastro aplicava até a v0.68 e que, no gmail, removiam
|
|
545
|
-
* pontos e sub-endereço `+tag`). Só aceita quando essas formas apontam para
|
|
546
|
-
* EXATAMENTE UMA conta — empate é tratado como "não achei".
|
|
547
|
-
*
|
|
548
|
-
* Existe para que as contas que nasceram com o endereço mutilado
|
|
549
|
-
* (`davi.carvalho96@gmail.com` gravado como `davicarvalho96@gmail.com`)
|
|
550
|
-
* continuem tendo caminho de volta. Default: `true` — desligar tranca essas
|
|
551
|
-
* contas do lado de fora. Depois de migrar os endereços gravados, desligue e,
|
|
552
|
-
* mais adiante, a ponte inteira sai da lib (ver `host/email_identifier.ts`).
|
|
553
|
-
*/
|
|
554
|
-
legacyEmailFallback?: boolean;
|
|
555
540
|
}
|
|
556
541
|
export interface ResolvedLoginConfig {
|
|
557
542
|
requireVerifiedEmail: boolean;
|
|
558
543
|
otp: ResolvedOtpLoginConfig;
|
|
559
|
-
legacyEmailFallback: boolean;
|
|
560
544
|
}
|
|
561
545
|
export declare function resolveLogin(input?: LoginConfigInput): ResolvedLoginConfig;
|
|
562
546
|
/**
|
|
@@ -137,7 +137,6 @@ export function resolveLogin(input) {
|
|
|
137
137
|
return {
|
|
138
138
|
requireVerifiedEmail: input?.requireVerifiedEmail ?? false,
|
|
139
139
|
otp: resolveOtpLoginConfig(input?.otp),
|
|
140
|
-
legacyEmailFallback: input?.legacyEmailFallback ?? true,
|
|
141
140
|
};
|
|
142
141
|
}
|
|
143
142
|
export function resolveRegistration(input) {
|
|
@@ -4,7 +4,7 @@ import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../../pagination.js';
|
|
|
4
4
|
import { PasswordPolicyError } from '../../password/password_manager.js';
|
|
5
5
|
import { AccountDeletionService } from '../account_deletion_service.js';
|
|
6
6
|
import { sendPasswordResetEmail } from '../default_mailer.js';
|
|
7
|
-
import { normalizeEmailIdentifier
|
|
7
|
+
import { normalizeEmailIdentifier } from '../email_identifier.js';
|
|
8
8
|
import { authkitOrigin } from '../origin.js';
|
|
9
9
|
import { resolveEffectiveRolesCatalog } from '../runtime_toggles.js';
|
|
10
10
|
/**
|
|
@@ -29,12 +29,10 @@ export class AdminUsersService {
|
|
|
29
29
|
// serviço também é chamado direto (console/API/host) — normaliza aqui para
|
|
30
30
|
// que nenhum caminho grave um endereço que o login não encontra.
|
|
31
31
|
const email = normalizeEmailIdentifier(input.email);
|
|
32
|
-
// Duplicado
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
const existing =
|
|
36
|
-
legacyFallback: this.cfg.login?.legacyEmailFallback ?? true,
|
|
37
|
-
})).account;
|
|
32
|
+
// Duplicado pela MESMA forma que o login busca — a normalizada. Contas
|
|
33
|
+
// gravadas com outra grafia (import/convite antigos) só entram nesta conta
|
|
34
|
+
// depois do `authkit:users:normalize-emails`.
|
|
35
|
+
const existing = await store.findByEmail(email);
|
|
38
36
|
if (existing)
|
|
39
37
|
return { ok: false, reason: 'email_taken' };
|
|
40
38
|
const hasPassword = !!input.password;
|