@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
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
3
+ *
4
+ * Faz `trim()` + `toLowerCase()` e MAIS NADA. O endereço que a pessoa digitou é a
5
+ * identidade dela: a lib NÃO decide que `a.b@gmail.com` e `ab@gmail.com` são a
6
+ * mesma pessoa, nem descarta o sub-endereço (`+tag`) que ela escolheu usar.
7
+ *
8
+ * POR QUE ISTO EXISTE: até a v0.68 o cadastro validava o e-mail com
9
+ * `.normalizeEmail()` do VineJS (os defaults do validator.js), que para o gmail
10
+ * REMOVE os pontos e o `+tag` do local part. A conta nascia com um endereço
11
+ * DIFERENTE do digitado (`davi.carvalho96@gmail.com` → `davicarvalho96@gmail.com`)
12
+ * enquanto o passo de identificador do login não normalizava NADA e buscava por
13
+ * igualdade exata — resultado: quem tinha ponto ou `+tag` no gmail ficava
14
+ * trancado do lado de fora, e, por o login ser à prova de enumeração, sem
15
+ * nenhuma mensagem de erro. Uma normalização só, usada nos DOIS lados, fecha a
16
+ * assimetria.
17
+ *
18
+ * Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
19
+ * "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
20
+ * import de usuários, cadastro social e o passo de identificador do login.
21
+ */
22
+ export function normalizeEmailIdentifier(raw) {
23
+ return typeof raw === 'string' ? raw.trim().toLowerCase() : '';
24
+ }
25
+ // ─── Ponte de compatibilidade com a normalização LEGADA ─────────────────────
26
+ /** Domínios do gmail (pontos e sub-endereço eram removidos do local part). */
27
+ const GMAIL_DOMAINS = ['gmail.com', 'googlemail.com'];
28
+ /** Domínios do iCloud (sub-endereço `+tag` removido). */
29
+ const ICLOUD_DOMAINS = ['icloud.com', 'me.com'];
30
+ /** Domínios do Outlook.com/Hotmail/Live (sub-endereço `+tag` removido). */
31
+ const OUTLOOK_DOMAINS = [
32
+ 'hotmail.at',
33
+ 'hotmail.be',
34
+ 'hotmail.ca',
35
+ 'hotmail.cl',
36
+ 'hotmail.co.il',
37
+ 'hotmail.co.nz',
38
+ 'hotmail.co.th',
39
+ 'hotmail.co.uk',
40
+ 'hotmail.com',
41
+ 'hotmail.com.ar',
42
+ 'hotmail.com.au',
43
+ 'hotmail.com.br',
44
+ 'hotmail.com.gr',
45
+ 'hotmail.com.mx',
46
+ 'hotmail.com.pe',
47
+ 'hotmail.com.tr',
48
+ 'hotmail.com.vn',
49
+ 'hotmail.cz',
50
+ 'hotmail.de',
51
+ 'hotmail.dk',
52
+ 'hotmail.es',
53
+ 'hotmail.fr',
54
+ 'hotmail.hu',
55
+ 'hotmail.id',
56
+ 'hotmail.ie',
57
+ 'hotmail.in',
58
+ 'hotmail.it',
59
+ 'hotmail.jp',
60
+ 'hotmail.kr',
61
+ 'hotmail.lv',
62
+ 'hotmail.my',
63
+ 'hotmail.ph',
64
+ 'hotmail.pt',
65
+ 'hotmail.sa',
66
+ 'hotmail.sg',
67
+ 'hotmail.sk',
68
+ 'live.be',
69
+ 'live.co.uk',
70
+ 'live.com',
71
+ 'live.com.ar',
72
+ 'live.com.mx',
73
+ 'live.de',
74
+ 'live.es',
75
+ 'live.eu',
76
+ 'live.fr',
77
+ 'live.it',
78
+ 'live.nl',
79
+ 'msn.com',
80
+ 'outlook.at',
81
+ 'outlook.be',
82
+ 'outlook.cl',
83
+ 'outlook.co.il',
84
+ 'outlook.co.nz',
85
+ 'outlook.co.th',
86
+ 'outlook.com',
87
+ 'outlook.com.ar',
88
+ 'outlook.com.au',
89
+ 'outlook.com.br',
90
+ 'outlook.com.gr',
91
+ 'outlook.com.pe',
92
+ 'outlook.com.tr',
93
+ 'outlook.com.vn',
94
+ 'outlook.cz',
95
+ 'outlook.de',
96
+ 'outlook.dk',
97
+ 'outlook.es',
98
+ 'outlook.fr',
99
+ 'outlook.hu',
100
+ 'outlook.id',
101
+ 'outlook.ie',
102
+ 'outlook.in',
103
+ 'outlook.it',
104
+ 'outlook.jp',
105
+ 'outlook.kr',
106
+ 'outlook.lv',
107
+ 'outlook.my',
108
+ 'outlook.ph',
109
+ 'outlook.pt',
110
+ 'outlook.sa',
111
+ 'outlook.sg',
112
+ 'outlook.sk',
113
+ 'passport.com',
114
+ ];
115
+ /** Domínios do Yahoo (sub-endereço `-tag` removido). */
116
+ const YAHOO_DOMAINS = [
117
+ 'rocketmail.com',
118
+ 'yahoo.ca',
119
+ 'yahoo.co.uk',
120
+ 'yahoo.com',
121
+ 'yahoo.de',
122
+ 'yahoo.fr',
123
+ 'yahoo.in',
124
+ 'yahoo.it',
125
+ 'ymail.com',
126
+ ];
127
+ /** Domínios do Yandex (todos colapsavam em `yandex.ru`). */
128
+ const YANDEX_DOMAINS = ['yandex.ru', 'yandex.ua', 'yandex.kz', 'yandex.com', 'yandex.by', 'ya.ru'];
129
+ /** Remove pontos SOLTOS do local part (pontos consecutivos ficam — regra do validator.js). */
130
+ function stripSingleDots(local) {
131
+ return local.replace(/\.+/g, (match) => (match.length > 1 ? match : ''));
132
+ }
133
+ /**
134
+ * Réplica da normalização LEGADA — `normalizeEmail()` do validator.js com os
135
+ * defaults, que é o que o `.normalizeEmail()` do VineJS aplicava no cadastro até
136
+ * a v0.68. Existe SÓ para reencontrar as contas que nasceram com o endereço
137
+ * mutilado; nada novo deve ser gravado com ela.
138
+ *
139
+ * É uma PONTE TEMPORÁRIA: quando as contas antigas tiverem sido migradas para o
140
+ * endereço real (ou o suficiente delas), esta função, a opção
141
+ * `login.legacyEmailFallback` e {@link resolveEmailIdentifier} podem sair.
142
+ *
143
+ * Retorna `null` para entradas que o validator.js também recusaria (local part
144
+ * vazio depois do colapso) ou que não são um endereço com `@`.
145
+ */
146
+ export function legacyNormalizeEmailIdentifier(raw) {
147
+ const email = normalizeEmailIdentifier(raw);
148
+ const at = email.lastIndexOf('@');
149
+ if (at <= 0 || at === email.length - 1)
150
+ return null;
151
+ let local = email.slice(0, at);
152
+ let domain = email.slice(at + 1);
153
+ if (GMAIL_DOMAINS.includes(domain)) {
154
+ local = local.split('+')[0];
155
+ local = stripSingleDots(local);
156
+ domain = 'gmail.com';
157
+ }
158
+ else if (ICLOUD_DOMAINS.includes(domain) || OUTLOOK_DOMAINS.includes(domain)) {
159
+ local = local.split('+')[0];
160
+ }
161
+ else if (YAHOO_DOMAINS.includes(domain)) {
162
+ const parts = local.split('-');
163
+ local = parts.length > 1 ? parts.slice(0, -1).join('-') : parts[0];
164
+ }
165
+ else if (YANDEX_DOMAINS.includes(domain)) {
166
+ domain = 'yandex.ru';
167
+ }
168
+ if (!local.length)
169
+ return null;
170
+ return `${local}@${domain}`;
171
+ }
172
+ /**
173
+ * Resolve o e-mail digitado na conta correspondente.
174
+ *
175
+ * Passe o valor **CRU** (como veio do formulário, do provider ou do arquivo): a
176
+ * normalização acontece aqui dentro, e a forma crua é uma das candidatas da
177
+ * ponte. Passar um valor já normalizado apaga essa candidata e reduz o alcance
178
+ * da ponte às contas gravadas com o endereço mutilado.
179
+ *
180
+ * 1. Busca pela forma normalizada NOVA (trim + lowercase) — o caminho de sempre,
181
+ * uma única query por igualdade (indexada).
182
+ * 2. Não achando, e com a ponte ligada (`login.legacyEmailFallback`, default
183
+ * `true`), tenta as formas de compatibilidade:
184
+ * - o endereço EXATAMENTE como digitado (só com `trim`), para as contas que
185
+ * foram gravadas com maiúsculas antes desta normalização existir (import,
186
+ * convite, criação por admin, provider social);
187
+ * - a {@link legacyNormalizeEmailIdentifier normalização legada}, para as
188
+ * contas que nasceram com o endereço mutilado.
189
+ * O resultado só é aceito quando as formas de compatibilidade apontam para
190
+ * EXATAMENTE UMA conta. Duas contas distintas (ex.: `Davi.C@Gmail.com` criada
191
+ * pelo social E `davic@gmail.com` criada pelo cadastro legado) são um empate:
192
+ * a lib não adivinha qual é a pessoa e trata como "não achei".
193
+ *
194
+ * LIMITE CONHECIDO: a ponte só alcança grafias que dá para derivar do que foi
195
+ * digitado. Uma conta gravada `Davi@Acme.com` é alcançada por quem digita
196
+ * `Davi@Acme.com` (a forma crua), mas NÃO por quem digita `davi@acme.com` — aí as
197
+ * três formas coincidem e só uma busca case-insensitive no store resolveria, o
198
+ * que exigiria varrer a tabela a cada login com e-mail desconhecido (justamente o
199
+ * caminho de ataque). Essas contas pedem migração do endereço gravado, não ponte.
200
+ *
201
+ * À PROVA DE ENUMERAÇÃO: nunca lança, nunca sinaliza nada para fora — quem chama
202
+ * segue com `account: null` exatamente como seguia antes. As buscas extras só
203
+ * acontecem quando as formas de compatibilidade DIFEREM da normalizada (e-mail
204
+ * digitado em minúsculas e sem ponto/tag → uma query só, como hoje).
205
+ */
206
+ export async function resolveEmailIdentifier(store, raw, options = {}) {
207
+ const email = normalizeEmailIdentifier(raw);
208
+ const miss = {
209
+ email,
210
+ lookupEmail: email,
211
+ account: null,
212
+ viaLegacyFallback: false,
213
+ };
214
+ if (!email)
215
+ return miss;
216
+ const direct = await store.findByEmail(email);
217
+ if (direct)
218
+ return { email, lookupEmail: email, account: direct, viaLegacyFallback: false };
219
+ if (options.legacyFallback === false)
220
+ return miss;
221
+ const typed = typeof raw === 'string' ? raw.trim() : '';
222
+ const legacy = legacyNormalizeEmailIdentifier(email);
223
+ const candidates = [typed, legacy].filter((candidate) => !!candidate && candidate !== email);
224
+ const byId = new Map();
225
+ for (const candidate of new Set(candidates)) {
226
+ const found = await store.findByEmail(candidate);
227
+ if (found)
228
+ byId.set(found.id, found);
229
+ }
230
+ if (byId.size !== 1)
231
+ return miss;
232
+ const account = [...byId.values()][0];
233
+ return { email, lookupEmail: account.email, account, viaLegacyFallback: true };
234
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Política EFETIVA de organizações — ponto de verdade ÚNICO das duas
3
+ * superfícies member-facing.
4
+ *
5
+ * O console HTML (`AccountOrgsController`) e o espelho JSON
6
+ * (`AccountOrgsApiController`) precisam responder à MESMA pergunta — "este
7
+ * usuário pode criar org? que papéis existem? quanto tempo o convite vale?" —
8
+ * e a resposta não está no config estático: ela é resolvida na ordem setting da
9
+ * org → setting global → `config.organizations` → default da lib.
10
+ *
11
+ * Mora num módulo próprio, e não dentro de um dos controllers, porque duas
12
+ * cópias desta resolução são exatamente como o caminho JSON acaba mais frouxo
13
+ * (ou mais apertado) que o formulário que ele espelha — o bug que este pacote
14
+ * de mudanças existe para não ter.
15
+ */
16
+ import './augmentations.js';
17
+ import type { HttpContext } from '@adonisjs/core/http';
18
+ import { type OrganizationsPolicyConfigDefaults, type ResolvedOrganizationsPolicySetting } from './runtime_toggles.js';
19
+ /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
20
+ export declare function orgPolicyDefaults(cfg: any): OrganizationsPolicyConfigDefaults;
21
+ /**
22
+ * Política efetiva para o `orgId` (ou global, quando ausente). `settings` nulo —
23
+ * DB fora do ar, app sem lucid — cai no config estático, fail-safe herdado de
24
+ * {@link resolveEffectiveOrganizationsPolicy}.
25
+ */
26
+ export declare function effectiveOrgPolicy(ctx: HttpContext, cfg: any, orgId?: string | null): Promise<ResolvedOrganizationsPolicySetting>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Política EFETIVA de organizações — ponto de verdade ÚNICO das duas
3
+ * superfícies member-facing.
4
+ *
5
+ * O console HTML (`AccountOrgsController`) e o espelho JSON
6
+ * (`AccountOrgsApiController`) precisam responder à MESMA pergunta — "este
7
+ * usuário pode criar org? que papéis existem? quanto tempo o convite vale?" —
8
+ * e a resposta não está no config estático: ela é resolvida na ordem setting da
9
+ * org → setting global → `config.organizations` → default da lib.
10
+ *
11
+ * Mora num módulo próprio, e não dentro de um dos controllers, porque duas
12
+ * cópias desta resolução são exatamente como o caminho JSON acaba mais frouxo
13
+ * (ou mais apertado) que o formulário que ele espelha — o bug que este pacote
14
+ * de mudanças existe para não ter.
15
+ */
16
+ import './augmentations.js';
17
+ import { resolveRuntimeSettings } from './runtime_settings.js';
18
+ import { resolveEffectiveOrganizationsPolicy, } from './runtime_toggles.js';
19
+ /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
20
+ export function orgPolicyDefaults(cfg) {
21
+ return {
22
+ roles: cfg.organizations.roles,
23
+ allowSelfCreate: cfg.organizations.allowSelfCreate,
24
+ invitationTtlHours: cfg.organizations.invitationTtlHours,
25
+ };
26
+ }
27
+ /**
28
+ * Política efetiva para o `orgId` (ou global, quando ausente). `settings` nulo —
29
+ * DB fora do ar, app sem lucid — cai no config estático, fail-safe herdado de
30
+ * {@link resolveEffectiveOrganizationsPolicy}.
31
+ */
32
+ export async function effectiveOrgPolicy(ctx, cfg, orgId) {
33
+ const settings = await resolveRuntimeSettings(ctx);
34
+ return resolveEffectiveOrganizationsPolicy(settings, orgPolicyDefaults(cfg), orgId);
35
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Chave de sessão do desafio WebAuthn PENDENTE de REGISTRO de passkey.
3
+ *
4
+ * Mora num módulo próprio porque a cerimônia agora tem DOIS pares de rotas — o
5
+ * clássico (`/account/mfa/passkeys/{options,verify}`, que responde redirect numa
6
+ * navegação) e o JSON (`/account/api/mfa/passkeys/{options,verify}`, que nunca
7
+ * navega). Os dois precisam ler e escrever o MESMO slot: um `begin` feito por um
8
+ * caminho tem de poder ser finalizado pelo outro, e — mais importante — um
9
+ * desafio só pode existir UMA vez por sessão. Duas constantes com o mesmo valor
10
+ * em arquivos diferentes seriam a mesma coisa até alguém mudar uma delas.
11
+ */
12
+ export declare const PASSKEY_REG_CHALLENGE_KEY = "authkit_passkey_reg_challenge";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Chave de sessão do desafio WebAuthn PENDENTE de REGISTRO de passkey.
3
+ *
4
+ * Mora num módulo próprio porque a cerimônia agora tem DOIS pares de rotas — o
5
+ * clássico (`/account/mfa/passkeys/{options,verify}`, que responde redirect numa
6
+ * navegação) e o JSON (`/account/api/mfa/passkeys/{options,verify}`, que nunca
7
+ * navega). Os dois precisam ler e escrever o MESMO slot: um `begin` feito por um
8
+ * caminho tem de poder ser finalizado pelo outro, e — mais importante — um
9
+ * desafio só pode existir UMA vez por sessão. Duas constantes com o mesmo valor
10
+ * em arquivos diferentes seriam a mesma coisa até alguém mudar uma delas.
11
+ */
12
+ export const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';
@@ -211,6 +211,10 @@ const C = {
211
211
  apiKeys: () => import('./admin_api/api_keys_controller.js'),
212
212
  // Account self-service JSON API (session-authed, under /account/api/*).
213
213
  accountApi: () => import('./account_api/account_api_controller.js'),
214
+ // Espelhos JSON das ESCRITAS de org e do segundo fator — mesmos guards e
215
+ // mesma política do console HTML, resposta em JSON (ver os docblocks deles).
216
+ accountOrgsApi: () => import('./account_api/account_orgs_api_controller.js'),
217
+ accountMfaApi: () => import('./account_api/account_mfa_api_controller.js'),
214
218
  // API headless (Clerk-style) — host-session-authed via `headless.resolveAccountId`.
215
219
  headlessLoginMethods: () => import('./controllers/headless_login_methods_controller.js'),
216
220
  };
@@ -653,6 +657,53 @@ export function registerAuthHost(router, opts = {}) {
653
657
  router.get(`${apiBase}/orgs`, [C.accountApi, 'listOrgs']);
654
658
  router.get(`${apiBase}/orgs/invitations`, [C.accountApi, 'listOrgInvitations']);
655
659
  router.get(`${apiBase}/orgs/:id`, [C.accountApi, 'showOrg']);
660
+ // ─── Orgs: ESCRITA em JSON (espelho dos POSTs de formulário) ─────────
661
+ // SEMPRE montadas, como as leituras JSON logo acima — e NÃO amarradas ao
662
+ // `mountOrgs` da TELA. A tela é a UI do console; `/account/api/*` é a
663
+ // superfície de máquina, e quem desliga a tela é justamente o host que
664
+ // desenha as próprias telas e mais precisa destes endpoints. Amarrar as
665
+ // duas coisas tornaria o modo headless inalcançável.
666
+ //
667
+ // Desligar a tela não afrouxa nada: o que decide quem pode o quê aqui é o
668
+ // `accountGuard` + capability-probe do store + política efetiva
669
+ // (`allowSelfCreate` continua `false` por default) + papel na org — os
670
+ // mesmos gates do formulário, nunca a presença de uma rota HTML.
671
+ // ⚠️ ORDER MATTERS, de novo: segmento fixo antes de paramétrico.
672
+ router.post(`${apiBase}/orgs`, [C.accountOrgsApi, 'createOrg']);
673
+ router.post(`${apiBase}/orgs/deactivate`, [C.accountOrgsApi, 'deactivateOrg']);
674
+ router.post(`${apiBase}/orgs/invitations/:token/accept`, [
675
+ C.accountOrgsApi,
676
+ 'acceptInvitation',
677
+ ]);
678
+ router.post(`${apiBase}/orgs/:id/activate`, [C.accountOrgsApi, 'activateOrg']);
679
+ router.post(`${apiBase}/orgs/:id/leave`, [C.accountOrgsApi, 'leaveOrg']);
680
+ router.post(`${apiBase}/orgs/:id/invitations`, [C.accountOrgsApi, 'inviteMember']);
681
+ router.delete(`${apiBase}/orgs/:id/invitations/:invId`, [
682
+ C.accountOrgsApi,
683
+ 'revokeInvitation',
684
+ ]);
685
+ router.patch(`${apiBase}/orgs/:id/members/:accountId`, [
686
+ C.accountOrgsApi,
687
+ 'updateMemberRole',
688
+ ]);
689
+ router.delete(`${apiBase}/orgs/:id/members/:accountId`, [C.accountOrgsApi, 'removeMember']);
690
+ // ─── Segundo fator em JSON (espelho do console de MFA) ───────────────
691
+ // Mesma decisão das de org: superfície de máquina, montada sempre. Sem a
692
+ // capacidade de MFA no store, cada handler responde 422
693
+ // `capability_unsupported` — capability-probed, como o resto do
694
+ // `/account/api/*`.
695
+ router.post(`${apiBase}/mfa/totp/enroll`, [C.accountMfaApi, 'enrollTotp']);
696
+ // THROTTLE no confirm: o código TOTP é adivinhável (6 dígitos), e o
697
+ // `accountGuard` sozinho só exige uma sessão viva — que quem está
698
+ // tentando adivinhar tem. Bucket de SUDO (por IP): mesma natureza —
699
+ // usuário autenticado reprovando um fator —, contagem separada do login.
700
+ // O form clássico não tem isto; o JSON fica MAIS apertado, que é a única
701
+ // direção em que os dois caminhos podem divergir.
702
+ withSudo(router.post(`${apiBase}/mfa/totp/confirm`, [C.accountMfaApi, 'confirmTotp']));
703
+ router.post(`${apiBase}/mfa/totp/disable`, [C.accountMfaApi, 'disableTotp']);
704
+ router.post(`${apiBase}/mfa/recovery-codes`, [C.accountMfaApi, 'regenerateRecoveryCodes']);
705
+ router.post(`${apiBase}/mfa/passkeys/options`, [C.accountMfaApi, 'passkeyRegisterOptions']);
706
+ router.post(`${apiBase}/mfa/passkeys/verify`, [C.accountMfaApi, 'passkeyRegisterVerify']);
656
707
  })
657
708
  .use([accountGuard]);
658
709
  // API headless (Clerk-style) — montada fora do `accountGuard` (não depende da
@@ -108,6 +108,23 @@ export declare function markSudo(ctx: HttpContext): void;
108
108
  * @returns `true` se o sudo está ativo (dentro da graça); `false` caso contrário.
109
109
  */
110
110
  export declare function isSudoActive(ctx: HttpContext, graceMinutes: number): boolean;
111
+ /**
112
+ * A DECISÃO de sudo, sem efeito colateral nenhum na resposta: `true` quando a
113
+ * requisição pode seguir, `false` quando o usuário precisa reconfirmar a
114
+ * identidade.
115
+ *
116
+ * Existe porque há DUAS formas de recusar a mesma coisa. O console HTML
117
+ * redireciona para `/account/confirm` (`requireSudo`); o espelho JSON de
118
+ * `/account/api/*` responde `403 { error: { code: 'sudo_required' } }`, porque
119
+ * uma SPA que recebe uma página de login onde esperava JSON não tem como
120
+ * reagir. A POLÍTICA — toggle `sudo_mode`, janela de graça, vinculação à conta,
121
+ * fail-safe da setting e fail-closed da marca — tem de ser UMA só: duas cópias
122
+ * dela são como o caminho JSON acaba mais frouxo que o formulário.
123
+ *
124
+ * Toda a discussão de fail-safe × fail-closed do {@link requireSudo} vale aqui
125
+ * sem mudança: é literalmente o mesmo código.
126
+ */
127
+ export declare function isSudoSatisfied(ctx: HttpContext, settings: SettingsCapability | null): Promise<boolean>;
111
128
  /**
112
129
  * Guard de sudo mode. Verifica se a confirmação de identidade está ativa e
113
130
  * dentro da janela de graça. Se estiver, retorna `true`. Se não, redireciona
@@ -146,6 +146,36 @@ export function isSudoActive(ctx, graceMinutes) {
146
146
  const graceMs = graceMinutes * 60 * 1000;
147
147
  return Date.now() - sudoAt <= graceMs;
148
148
  }
149
+ /**
150
+ * A DECISÃO de sudo, sem efeito colateral nenhum na resposta: `true` quando a
151
+ * requisição pode seguir, `false` quando o usuário precisa reconfirmar a
152
+ * identidade.
153
+ *
154
+ * Existe porque há DUAS formas de recusar a mesma coisa. O console HTML
155
+ * redireciona para `/account/confirm` (`requireSudo`); o espelho JSON de
156
+ * `/account/api/*` responde `403 { error: { code: 'sudo_required' } }`, porque
157
+ * uma SPA que recebe uma página de login onde esperava JSON não tem como
158
+ * reagir. A POLÍTICA — toggle `sudo_mode`, janela de graça, vinculação à conta,
159
+ * fail-safe da setting e fail-closed da marca — tem de ser UMA só: duas cópias
160
+ * dela são como o caminho JSON acaba mais frouxo que o formulário.
161
+ *
162
+ * Toda a discussão de fail-safe × fail-closed do {@link requireSudo} vale aqui
163
+ * sem mudança: é literalmente o mesmo código.
164
+ */
165
+ export async function isSudoSatisfied(ctx, settings) {
166
+ try {
167
+ const cfg = settings ? await resolveEffectiveSudoMode(settings) : SUDO_MODE_DEFAULTS;
168
+ if (!cfg.enabled)
169
+ return true;
170
+ return isSudoActive(ctx, cfg.graceMinutes);
171
+ }
172
+ catch {
173
+ // FAIL-SAFE: erro ao resolver a setting → deixa passar. Disponibilidade, não
174
+ // identidade — ver o docblock de `requireSudo` para o porquê de isto NÃO
175
+ // contradizer o fail-closed de `isSudoActive`.
176
+ return true;
177
+ }
178
+ }
149
179
  /**
150
180
  * Guard de sudo mode. Verifica se a confirmação de identidade está ativa e
151
181
  * dentro da janela de graça. Se estiver, retorna `true`. Se não, redireciona
@@ -202,19 +232,8 @@ export function isSudoActive(ctx, graceMinutes) {
202
232
  * postura é fail-closed de novo.
203
233
  */
204
234
  export async function requireSudo(ctx, settings) {
205
- try {
206
- const cfg = settings ? await resolveEffectiveSudoMode(settings) : SUDO_MODE_DEFAULTS;
207
- if (!cfg.enabled)
208
- return true;
209
- if (isSudoActive(ctx, cfg.graceMinutes))
210
- return true;
211
- }
212
- catch {
213
- // FAIL-SAFE: erro ao resolver a setting → deixa passar. Disponibilidade, não
214
- // identidade — ver o docblock acima para o porquê de isto NÃO contradizer o
215
- // fail-closed de `isSudoActive`.
235
+ if (await isSudoSatisfied(ctx, settings))
216
236
  return true;
217
- }
218
237
  // Fora da graça: redireciona para confirmação.
219
238
  const rawUrl = ctx.request.url?.() ?? '';
220
239
  const qs = ctx.request.parsedUrl?.search ?? '';