@adonis-agora/authkit-server 0.69.0 → 0.70.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.
Files changed (45) hide show
  1. package/build/commands/import_users.js +6 -1
  2. package/build/index.d.ts +3 -1
  3. package/build/index.js +5 -1
  4. package/build/src/accounts/account_store.d.ts +40 -0
  5. package/build/src/accounts/account_store.js +20 -0
  6. package/build/src/accounts/lucid_store/mfa.js +22 -0
  7. package/build/src/audit/audit_sink.d.ts +1 -1
  8. package/build/src/audit/audit_sink.js +4 -0
  9. package/build/src/commands/import_users.d.ts +7 -0
  10. package/build/src/commands/import_users.js +15 -3
  11. package/build/src/define_config.d.ts +16 -0
  12. package/build/src/define_config.js +1 -0
  13. package/build/src/host/account_api/account_api_controller.d.ts +2 -0
  14. package/build/src/host/account_api/account_api_controller.js +22 -3
  15. package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
  16. package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
  17. package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
  18. package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
  19. package/build/src/host/account_lockout.js +7 -2
  20. package/build/src/host/admin_api/admin_users_service.js +13 -3
  21. package/build/src/host/admin_api/dto.d.ts +1 -1
  22. package/build/src/host/admin_validators.d.ts +2 -2
  23. package/build/src/host/admin_validators.js +3 -2
  24. package/build/src/host/controllers/account_mfa_controller.js +1 -2
  25. package/build/src/host/controllers/account_orgs_controller.js +4 -18
  26. package/build/src/host/controllers/account_security_controller.js +3 -1
  27. package/build/src/host/controllers/account_session_controller.js +13 -6
  28. package/build/src/host/controllers/interaction_controller.d.ts +10 -0
  29. package/build/src/host/controllers/interaction_controller.js +93 -34
  30. package/build/src/host/controllers/registration_controller.js +35 -9
  31. package/build/src/host/controllers/social_controller.js +11 -2
  32. package/build/src/host/email_identifier.d.ts +92 -0
  33. package/build/src/host/email_identifier.js +234 -0
  34. package/build/src/host/org_policy.d.ts +26 -0
  35. package/build/src/host/org_policy.js +35 -0
  36. package/build/src/host/passkey_registration_challenge.d.ts +12 -0
  37. package/build/src/host/passkey_registration_challenge.js +12 -0
  38. package/build/src/host/register_auth_host.js +51 -0
  39. package/build/src/host/sudo_mode.d.ts +17 -0
  40. package/build/src/host/sudo_mode.js +31 -12
  41. package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
  42. package/build/src/host/ui-dist/index.html +1 -1
  43. package/build/src/host/validators.d.ts +5 -5
  44. package/build/src/host/validators.js +17 -5
  45. package/package.json +2 -2
@@ -53,7 +53,12 @@ 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, { dryRun: this.dryRun });
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
+ });
57
62
  // Erros de parsing entram no relatório agregado.
58
63
  report.errors.unshift(...parseErrors);
59
64
  if (this.dryRun) {
package/build/index.d.ts CHANGED
@@ -64,6 +64,8 @@ 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
69
  export type { ResolveGeo } from './src/host/geo.js';
68
70
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
69
71
  export type { AuthMessages, I18nConfig } from './src/host/i18n.js';
@@ -94,7 +96,7 @@ export { sudoMethods } from './src/host/sudo/index.js';
94
96
  export { completeSudo, fail as failSudo, LAST_METHOD_SESSION_KEY, sudoContextFrom, } from './src/host/sudo/runtime.js';
95
97
  export type { SudoContext, SudoMethod, SudoMethodDescriptor, SudoRouteHelpers, } from './src/host/sudo/types.js';
96
98
  export type { ResolvedSudoModeSetting, SudoModeSetting, } from './src/host/sudo_mode.js';
97
- export { isSudoActive, markSudo, requireSudo, resolveEffectiveSudoMode, SUDO_ACCOUNT_SESSION_KEY, SUDO_MODE_DEFAULTS, SUDO_SESSION_KEY, } from './src/host/sudo_mode.js';
99
+ export { isSudoActive, isSudoSatisfied, markSudo, requireSudo, resolveEffectiveSudoMode, SUDO_ACCOUNT_SESSION_KEY, SUDO_MODE_DEFAULTS, SUDO_SESSION_KEY, } from './src/host/sudo_mode.js';
98
100
  export { barChartSvg } from './src/host/svg_chart.js';
99
101
  export type { ResolvedTrustedDevicesConfig, TrustedDevicePayload, TrustedDevicesConfigInput, } from './src/host/trusted_device.js';
100
102
  export { buildTrustedDevicePayload, isTrustedDeviceValid, resolveTrustedDevices, TRUSTED_DEVICE_COOKIE, } from './src/host/trusted_device.js';
package/build/index.js CHANGED
@@ -49,6 +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
53
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
53
54
  export { BUILTIN_MESSAGES, DEFAULT_LOCALE, DEFAULT_MESSAGES, PT_BR_MESSAGES, resolveMessages, translate, } from './src/host/i18n.js';
54
55
  export { ensureConsoleSession } from './src/host/idp_session_bridge.js';
@@ -90,7 +91,10 @@ export { sudoMethods } from './src/host/sudo/index.js';
90
91
  // `returnTo` montado na mão é um open redirect esperando acontecer.
91
92
  export { completeSudo, fail as failSudo, LAST_METHOD_SESSION_KEY, sudoContextFrom, } from './src/host/sudo/runtime.js';
92
93
  // Sudo mode — helpers for host controllers that require step-up authentication.
93
- export { isSudoActive, markSudo, requireSudo, resolveEffectiveSudoMode, SUDO_ACCOUNT_SESSION_KEY, SUDO_MODE_DEFAULTS, SUDO_SESSION_KEY, } from './src/host/sudo_mode.js';
94
+ export { isSudoActive,
95
+ // A DECISÃO de sudo sem efeito na resposta — o que o host chama quando quer
96
+ // recusar em JSON em vez de redirecionar para `/account/confirm`.
97
+ isSudoSatisfied, markSudo, requireSudo, resolveEffectiveSudoMode, SUDO_ACCOUNT_SESSION_KEY, SUDO_MODE_DEFAULTS, SUDO_SESSION_KEY, } from './src/host/sudo_mode.js';
94
98
  export { barChartSvg } from './src/host/svg_chart.js';
95
99
  export { buildTrustedDevicePayload, isTrustedDeviceValid, resolveTrustedDevices, TRUSTED_DEVICE_COOKIE, } from './src/host/trusted_device.js';
96
100
  export { parseUserAgent } from './src/host/user_agent.js';
@@ -278,6 +278,26 @@ export interface MfaCapability {
278
278
  consumeRecoveryCode(accountId: string, code: string): Promise<boolean>;
279
279
  /** Desliga o MFA: limpa segredo + mfaEnabledAt + recovery codes. */
280
280
  disableMfa(accountId: string): Promise<void>;
281
+ /**
282
+ * Quantos recovery codes AINDA não foram consumidos. OPCIONAL: existe para a
283
+ * tela de segundo fator dizer "restam N" sem revelar os códigos (só o hash
284
+ * fica guardado). `null` quando o MFA não está ativo.
285
+ *
286
+ * Opcional de propósito: um store de terceiro escrito contra a versão
287
+ * anterior de {@link MfaCapability} continua satisfazendo o tipo, e os
288
+ * callers fazem capability-probe (`typeof store.countRecoveryCodes ===
289
+ * 'function'`) antes de chamar.
290
+ */
291
+ countRecoveryCodes?(accountId: string): Promise<number | null>;
292
+ /**
293
+ * Regenera os recovery codes de uma conta com MFA ATIVO: descarta os antigos
294
+ * (inclusive os não usados) e devolve os novos em claro — uma única vez, como
295
+ * no {@link MfaCapability.confirmTotpEnrollment}. `null` quando o MFA não
296
+ * está ativo (não há o que regenerar).
297
+ *
298
+ * Opcional pelo mesmo motivo de {@link MfaCapability.countRecoveryCodes}.
299
+ */
300
+ regenerateRecoveryCodes?(accountId: string): Promise<string[] | null>;
281
301
  }
282
302
  /**
283
303
  * MFA / WebAuthn (passkeys) — 2º fator alternativo ao TOTP. Como o TOTP, é uma
@@ -654,6 +674,26 @@ export declare function supportsCountByGlobalRole(store: AccountStore): store is
654
674
  };
655
675
  /** Type guard: o store implementa a capacidade de passkeys / WebAuthn. */
656
676
  export declare function supportsPasskeys(store: AccountStore): store is AccountStore & WebauthnCapability;
677
+ /**
678
+ * Type guard: o store sabe REGENERAR recovery codes
679
+ * ({@link MfaCapability.regenerateRecoveryCodes}).
680
+ *
681
+ * Guard PRÓPRIO, separado de {@link supportsMfa}, porque o método é opcional
682
+ * dentro da capacidade: um store escrito antes desta versão implementa MFA
683
+ * inteiro e não implementa este método. Quem oferece o botão "gerar novos
684
+ * códigos" pergunta por aqui.
685
+ */
686
+ export declare function supportsRecoveryCodeRegeneration(store: AccountStore): store is AccountStore & MfaCapability & {
687
+ regenerateRecoveryCodes(accountId: string): Promise<string[] | null>;
688
+ };
689
+ /**
690
+ * Type guard: o store sabe CONTAR recovery codes restantes
691
+ * ({@link MfaCapability.countRecoveryCodes}). Mesmo motivo do
692
+ * {@link supportsRecoveryCodeRegeneration} para ser um guard separado.
693
+ */
694
+ export declare function supportsRecoveryCodeCount(store: AccountStore): store is AccountStore & MfaCapability & {
695
+ countRecoveryCodes(accountId: string): Promise<number | null>;
696
+ };
657
697
  /** Type guard: o store implementa account linking por identidade de provider. */
658
698
  export declare function supportsProviderIdentity(store: AccountStore): store is AccountStore & ProviderIdentityCapability;
659
699
  /** Type guard: o store implementa o self-service de segurança (senha/e-mail). */
@@ -18,6 +18,26 @@ export function supportsCountByGlobalRole(store) {
18
18
  export function supportsPasskeys(store) {
19
19
  return typeof store.listPasskeys === 'function';
20
20
  }
21
+ /**
22
+ * Type guard: o store sabe REGENERAR recovery codes
23
+ * ({@link MfaCapability.regenerateRecoveryCodes}).
24
+ *
25
+ * Guard PRÓPRIO, separado de {@link supportsMfa}, porque o método é opcional
26
+ * dentro da capacidade: um store escrito antes desta versão implementa MFA
27
+ * inteiro e não implementa este método. Quem oferece o botão "gerar novos
28
+ * códigos" pergunta por aqui.
29
+ */
30
+ export function supportsRecoveryCodeRegeneration(store) {
31
+ return typeof store.regenerateRecoveryCodes === 'function';
32
+ }
33
+ /**
34
+ * Type guard: o store sabe CONTAR recovery codes restantes
35
+ * ({@link MfaCapability.countRecoveryCodes}). Mesmo motivo do
36
+ * {@link supportsRecoveryCodeRegeneration} para ser um guard separado.
37
+ */
38
+ export function supportsRecoveryCodeCount(store) {
39
+ return typeof store.countRecoveryCodes === 'function';
40
+ }
21
41
  /** Type guard: o store implementa account linking por identidade de provider. */
22
42
  export function supportsProviderIdentity(store) {
23
43
  return typeof store.findByProviderIdentity === 'function';
@@ -100,6 +100,28 @@ export function buildMfa(ctx) {
100
100
  await repo.upsert(accountId, { recoveryCodes: remaining });
101
101
  return true;
102
102
  },
103
+ async countRecoveryCodes(accountId) {
104
+ const state = await repo.read(accountId);
105
+ // Sem MFA ativo não há conjunto de códigos a contar — `null` diz "não se
106
+ // aplica", diferente de `0` ("acabaram, gere novos").
107
+ if (!state || !state.mfaEnabledAt)
108
+ return null;
109
+ return Array.isArray(state.recoveryCodes) ? state.recoveryCodes.length : 0;
110
+ },
111
+ async regenerateRecoveryCodes(accountId) {
112
+ const state = await repo.read(accountId);
113
+ // Só regenera sobre MFA JÁ ATIVO. Num enrollment pendente os códigos saem
114
+ // do `confirmTotpEnrollment`; deixar regenerar antes disso criaria um
115
+ // conjunto válido de credenciais de recuperação para um fator que o
116
+ // usuário ainda não provou possuir.
117
+ if (!state || !state.mfaEnabledAt)
118
+ return null;
119
+ const codes = Array.from({ length: recoveryCodeCount }, () => generateRecoveryCode());
120
+ // Substitui o conjunto inteiro: os antigos (inclusive os não usados)
121
+ // param de valer no mesmo instante — é esse o ponto de "regenerar".
122
+ await repo.upsert(accountId, { recoveryCodes: codes.map(sha256) });
123
+ return codes;
124
+ },
103
125
  async disableMfa(accountId) {
104
126
  // Limpa todo o estado de MFA. Inclui o anti-replay: um futuro re-enroll começa do zero.
105
127
  await repo.clear(accountId);
@@ -8,7 +8,7 @@
8
8
  * varre os `audit.record({ type: ... })` do `src/` e falha quando um evento
9
9
  * emitido não está aqui.
10
10
  */
11
- export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated'];
11
+ export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'mfa.recovery_codes_regenerated', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated'];
12
12
  /** Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP. */
13
13
  export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[number];
14
14
  /**
@@ -29,6 +29,10 @@ export const AUDIT_EVENT_TYPES = [
29
29
  'impersonation.stopped',
30
30
  'mfa.enabled',
31
31
  'mfa.disabled',
32
+ // Os recovery codes foram TROCADOS: os antigos (inclusive os não usados)
33
+ // deixaram de valer no mesmo instante. Evento próprio, e não `mfa.enabled`,
34
+ // porque o fator não mudou — mudou o conjunto de credenciais de contorno.
35
+ 'mfa.recovery_codes_regenerated',
32
36
  'account.locked',
33
37
  'passkey.registered',
34
38
  'passkey.removed',
@@ -42,10 +42,17 @@ 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.
45
51
  */
46
52
  export declare function importUsers(store: AccountStore, records: {
47
53
  line: number;
48
54
  record: ImportUserRecord;
49
55
  }[], options?: {
50
56
  dryRun?: boolean;
57
+ legacyFallback?: boolean;
51
58
  }): Promise<ImportReport>;
@@ -1,4 +1,5 @@
1
1
  import { supportsAccountImport } from '../accounts/account_store.js';
2
+ import { normalizeEmailIdentifier, resolveEmailIdentifier } from '../host/email_identifier.js';
2
3
  /**
3
4
  * Faz o parse do conteúdo do arquivo: aceita NDJSON (uma linha JSON por usuário)
4
5
  * OU um array JSON. Retorna os registros + os erros de parsing por linha. PURO
@@ -47,18 +48,29 @@ export function parseImportFile(content) {
47
48
  *
48
49
  * Lógica PURA quanto a I/O de arquivo (recebe os registros já parseados) — fácil
49
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.
50
57
  */
51
58
  export async function importUsers(store, records, options = {}) {
52
59
  const report = { created: 0, skippedDuplicate: 0, errors: [] };
53
60
  const canImport = supportsAccountImport(store);
54
61
  for (const { line, record } of records) {
55
- const email = record.email?.trim();
62
+ // MESMA normalização do cadastro/login (`trim` + `toLowerCase`) — sem ela o
63
+ // import gravava `Davi@Acme.com` e o login (normalizado) não achava a conta.
64
+ const email = normalizeEmailIdentifier(record.email);
56
65
  if (!email) {
57
66
  report.errors.push({ line, reason: 'missing email' });
58
67
  continue;
59
68
  }
60
- // Duplicado: e-mail já existe → pula.
61
- const existing = await store.findByEmail(email);
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;
62
74
  if (existing) {
63
75
  report.skippedDuplicate++;
64
76
  continue;
@@ -537,10 +537,26 @@ 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;
540
555
  }
541
556
  export interface ResolvedLoginConfig {
542
557
  requireVerifiedEmail: boolean;
543
558
  otp: ResolvedOtpLoginConfig;
559
+ legacyEmailFallback: boolean;
544
560
  }
545
561
  export declare function resolveLogin(input?: LoginConfigInput): ResolvedLoginConfig;
546
562
  /**
@@ -137,6 +137,7 @@ export function resolveLogin(input) {
137
137
  return {
138
138
  requireVerifiedEmail: input?.requireVerifiedEmail ?? false,
139
139
  otp: resolveOtpLoginConfig(input?.otp),
140
+ legacyEmailFallback: input?.legacyEmailFallback ?? true,
140
141
  };
141
142
  }
142
143
  export function resolveRegistration(input) {
@@ -170,6 +170,8 @@ export default class AccountApiController {
170
170
  };
171
171
  recovery: {
172
172
  available: any;
173
+ remaining: number | null;
174
+ regenerable: boolean;
173
175
  };
174
176
  }>;
175
177
  /**
@@ -28,7 +28,7 @@
28
28
  * GET /account/api/orgs/invitations → convites pendentes
29
29
  */
30
30
  import '../augmentations.js';
31
- import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, } from '../../accounts/account_store.js';
31
+ import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, supportsRecoveryCodeCount, supportsRecoveryCodeRegeneration, } from '../../accounts/account_store.js';
32
32
  import { PasswordPolicyError } from '../../password/password_manager.js';
33
33
  import { accountPath } from '../account_paths.js';
34
34
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
@@ -627,6 +627,16 @@ export default class AccountApiController {
627
627
  /* fail-safe */
628
628
  }
629
629
  }
630
+ // Quantos recovery codes restam (capability-probed, fail-safe).
631
+ let recoveryRemaining = null;
632
+ if (supportsRecoveryCodeCount(cfg.accountStore)) {
633
+ try {
634
+ recoveryRemaining = await cfg.accountStore.countRecoveryCodes(userId);
635
+ }
636
+ catch {
637
+ /* fail-safe */
638
+ }
639
+ }
630
640
  return {
631
641
  enabled,
632
642
  totp: { enrolled: enabled },
@@ -639,8 +649,17 @@ export default class AccountApiController {
639
649
  createdAt: p.createdAt ?? null,
640
650
  })),
641
651
  },
642
- // Recovery codes are shown once via the existing POST /account/mfa/confirm flow.
643
- recovery: { available: enabled },
652
+ recovery: {
653
+ // Os códigos em si NUNCA voltam aqui — só no instante em que são
654
+ // criados (`/mfa/totp/confirm` ou `/mfa/recovery-codes`), porque é só
655
+ // o hash deles que fica guardado.
656
+ available: enabled,
657
+ // `remaining` é `null` quando o store não sabe contar (capacidade
658
+ // opcional) — diferente de `0`, que significa "acabaram, gere novos".
659
+ remaining: recoveryRemaining,
660
+ // O host só mostra o botão "gerar novos códigos" se houver como.
661
+ regenerable: supportsRecoveryCodeRegeneration(cfg.accountStore),
662
+ },
644
663
  };
645
664
  }
646
665
  // ─── GET /account/api/login-methods ─────────────────────────────────────
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Account Self-Service JSON API — SEGUNDO FATOR (TOTP + passkeys)
3
+ *
4
+ * Espelho JSON do `AccountMfaController`, para o host que desenha a própria
5
+ * tela de "segundo fator" e não pode navegar o browser no meio do fluxo.
6
+ *
7
+ * Mapa de rotas (sob o `accountGuard`; mutantes sob o CSRF do shield do host):
8
+ * POST /account/api/mfa/totp/enroll → inicia o enrolamento (sudo)
9
+ * POST /account/api/mfa/totp/confirm → confirma com o código (throttled)
10
+ * POST /account/api/mfa/totp/disable → desliga o MFA (sudo)
11
+ * POST /account/api/mfa/recovery-codes → regenera os códigos (sudo)
12
+ * POST /account/api/mfa/passkeys/options → options da cerimônia de registro
13
+ * POST /account/api/mfa/passkeys/verify → verifica o attestation (sudo)
14
+ *
15
+ * Paridade de gates com o console HTML, endpoint a endpoint:
16
+ *
17
+ * | ação | console HTML | aqui |
18
+ * | ---------------- | ------------ | ---------------- |
19
+ * | enroll | sudo | sudo → 403 JSON |
20
+ * | confirm | sem sudo | sem sudo |
21
+ * | disable | sudo | sudo → 403 JSON |
22
+ * | passkey options | sem sudo | sem sudo |
23
+ * | passkey verify | sudo | sudo → 403 JSON |
24
+ * | recovery codes | (não existe) | sudo → 403 JSON |
25
+ *
26
+ * A única diferença é a FORMA da recusa: `requireSudo` devolve um redirect para
27
+ * `/account/confirm`, que numa SPA chega como uma página HTML onde ela esperava
28
+ * JSON. Aqui a recusa vira `403 { error: { code: 'sudo_required' } }` e a tela
29
+ * do host manda o usuário confirmar identidade por conta própria. Mesma
30
+ * política, resposta legível por máquina.
31
+ *
32
+ * `confirm` NÃO exige sudo — porque o `enroll` que criou o segredo pendente já
33
+ * exigiu, e pedir de novo no passo seguinte quebraria o enrolamento de quem
34
+ * demorou a digitar o código. É exatamente o que o console faz. O que o console
35
+ * não tem, e aqui existe, é o THROTTLE: o código de 6 dígitos é adivinhável, e
36
+ * a rota carrega o bucket de sudo (por IP) — mais apertado que o form, nunca
37
+ * mais frouxo.
38
+ *
39
+ * Os dois caminhos (clássico e JSON) COMPARTILHAM o slot do desafio WebAuthn
40
+ * (`PASSKEY_REG_CHALLENGE_KEY`), então um `options` pedido num deles pode ser
41
+ * finalizado pelo outro e nunca existem dois desafios vivos na mesma sessão.
42
+ */
43
+ import '../augmentations.js';
44
+ import type { HttpContext } from '@adonisjs/core/http';
45
+ export default class AccountMfaApiController {
46
+ #private;
47
+ /**
48
+ * Inicia o enrolamento TOTP: segredo pendente + `otpauth://` URI + o QR já
49
+ * renderizado como data-URL (o console renderiza server-side; aqui a tela do
50
+ * host recebe pronto e decide se mostra o QR, o segredo, ou os dois).
51
+ */
52
+ enrollTotp(ctx: HttpContext): Promise<void | {
53
+ secret: string;
54
+ otpauthUri: string;
55
+ qrDataUrl: string;
56
+ }>;
57
+ /**
58
+ * Confirma o enrolamento com o código do app autenticador. Sucesso ativa o
59
+ * MFA e devolve os recovery codes — UMA vez, como no console (lá eles vão num
60
+ * flash; aqui, no corpo da resposta, que é o equivalente headless).
61
+ */
62
+ confirmTotp(ctx: HttpContext): Promise<void | {
63
+ ok: boolean;
64
+ enabled: boolean;
65
+ recoveryCodes: string[];
66
+ }>;
67
+ /** Desliga o MFA (TOTP + recovery codes). Exige sudo, como o console. */
68
+ disableTotp(ctx: HttpContext): Promise<{
69
+ ok: boolean;
70
+ enabled: boolean;
71
+ } | undefined>;
72
+ /**
73
+ * Regenera os recovery codes de uma conta com MFA ATIVO e devolve os novos —
74
+ * uma única vez. Exige sudo: o resultado é um conjunto de credenciais que
75
+ * contorna o segundo fator, então vale o mesmo gate do `enroll`/`disable`.
76
+ *
77
+ * Capability-probed: stores que não implementam
78
+ * `regenerateRecoveryCodes` respondem 422 em vez de 500.
79
+ */
80
+ regenerateRecoveryCodes(ctx: HttpContext): Promise<void | {
81
+ ok: boolean;
82
+ recoveryCodes: string[];
83
+ }>;
84
+ /**
85
+ * Options da cerimônia de REGISTRO de passkey, guardando o desafio na sessão.
86
+ * Sem sudo, igual ao endpoint clássico: quem paga o gate é o `verify`, que é
87
+ * onde a credencial passa a existir.
88
+ */
89
+ passkeyRegisterOptions(ctx: HttpContext): Promise<any>;
90
+ /**
91
+ * Verifica o attestation contra o desafio da sessão e persiste a credencial.
92
+ *
93
+ * O endpoint clássico responde 302 numa navegação e `{ok:true}` num fetch;
94
+ * este responde SEMPRE JSON — inclusive na recusa de sudo, que lá é um
95
+ * redirect. É a diferença que faz a cerimônia caber numa SPA: o browser não
96
+ * pode navegar entre `startRegistration()` e a verificação.
97
+ */
98
+ passkeyRegisterVerify(ctx: HttpContext): Promise<void | {
99
+ ok: boolean;
100
+ }>;
101
+ }