@adonis-agora/authkit-server 0.68.4 → 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 (54) hide show
  1. package/build/commands/import_users.js +6 -1
  2. package/build/index.d.ts +5 -1
  3. package/build/index.js +7 -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 +81 -0
  12. package/build/src/define_config.js +18 -3
  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_orgs_service.js +3 -2
  21. package/build/src/host/admin_api/admin_users_service.js +13 -3
  22. package/build/src/host/admin_api/dto.d.ts +1 -1
  23. package/build/src/host/admin_validators.d.ts +2 -2
  24. package/build/src/host/admin_validators.js +3 -2
  25. package/build/src/host/controllers/account_mfa_controller.js +1 -2
  26. package/build/src/host/controllers/account_orgs_controller.js +19 -11
  27. package/build/src/host/controllers/account_security_controller.js +3 -1
  28. package/build/src/host/controllers/account_session_controller.js +17 -6
  29. package/build/src/host/controllers/interaction_controller.d.ts +10 -0
  30. package/build/src/host/controllers/interaction_controller.js +93 -34
  31. package/build/src/host/controllers/registration_controller.js +35 -9
  32. package/build/src/host/controllers/social_controller.js +11 -2
  33. package/build/src/host/email_identifier.d.ts +92 -0
  34. package/build/src/host/email_identifier.js +234 -0
  35. package/build/src/host/idp_session_bridge.d.ts +55 -0
  36. package/build/src/host/idp_session_bridge.js +108 -0
  37. package/build/src/host/middleware/account_auth.js +3 -2
  38. package/build/src/host/org_policy.d.ts +26 -0
  39. package/build/src/host/org_policy.js +35 -0
  40. package/build/src/host/passkey_registration_challenge.d.ts +12 -0
  41. package/build/src/host/passkey_registration_challenge.js +12 -0
  42. package/build/src/host/register_auth_host.js +56 -1
  43. package/build/src/host/runtime_toggles.d.ts +2 -2
  44. package/build/src/host/runtime_toggles.js +3 -1
  45. package/build/src/host/sudo_mode.d.ts +17 -0
  46. package/build/src/host/sudo_mode.js +31 -12
  47. package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
  48. package/build/src/host/ui-dist/index.html +1 -1
  49. package/build/src/host/validators.d.ts +5 -5
  50. package/build/src/host/validators.js +17 -5
  51. package/build/src/provider/build_provider.js +46 -14
  52. package/build/src/provider/registration_policy.d.ts +99 -0
  53. package/build/src/provider/registration_policy.js +229 -0
  54. package/package.json +2 -2
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Account Self-Service JSON API — ESCRITA de organizações
3
+ *
4
+ * Espelho JSON dos POSTs de formulário do `AccountOrgsController`, para hosts
5
+ * que desenham as próprias telas de organização dentro do shell do produto e
6
+ * não querem mandar o usuário ao console `/account/orgs`.
7
+ *
8
+ * Mapa de rotas (todas sob o `accountGuard`; as mutantes sob o CSRF do shield
9
+ * do host — nenhuma delas entra em `authkitCsrfExceptions`):
10
+ * POST /account/api/orgs → criar org (allowSelfCreate)
11
+ * POST /account/api/orgs/deactivate → limpar a org ativa
12
+ * POST /account/api/orgs/invitations/:token/accept → aceitar convite
13
+ * POST /account/api/orgs/:id/activate → definir org ativa
14
+ * POST /account/api/orgs/:id/leave → sair da org
15
+ * POST /account/api/orgs/:id/invitations → convidar por e-mail
16
+ * DELETE /account/api/orgs/:id/invitations/:invId → revogar convite
17
+ * PATCH /account/api/orgs/:id/members/:accountId → trocar papel
18
+ * DELETE /account/api/orgs/:id/members/:accountId → remover membro
19
+ *
20
+ * O que este controller NÃO afrouxa em relação ao formulário:
21
+ *
22
+ * - **Escopo por conta.** O ator é sempre `session[ACCOUNT_SESSION_KEY]`;
23
+ * nenhum handler aceita um id de ator vindo do corpo.
24
+ * - **Papel na org.** Convidar, revogar convite, remover membro e trocar
25
+ * papel exigem membership `owner`/`admin` NA ORG DO PATH — a mesma checagem
26
+ * do form, que é o que impede o IDOR cross-org.
27
+ * - **Escalonamento.** Só um `owner` concede o papel `owner`; um admin
28
+ * tentando isso leva 403, igual ao form.
29
+ * - **Catálogo de papéis.** O papel passa pelo `isRoleInCatalog` (runtime →
30
+ * config → defaults), o MESMO helper puro do form e do caminho admin.
31
+ *
32
+ * O que muda de propósito: a resposta. Onde o form redireciona para
33
+ * `/account/orgs` (com ou sem flash), aqui sai JSON — `{ error: { code,
34
+ * message } }` com o status certo, para a tela do host poder reagir.
35
+ *
36
+ * Sudo: o console HTML NÃO exige sudo em nenhuma operação de org, e este
37
+ * espelho segue igual. Exigir aqui o que o form não exige seria divergência na
38
+ * outra direção — e a decisão de qual superfície é sensível pertence a uma
39
+ * mudança de política, não a um espelho de formato.
40
+ *
41
+ * Política EFETIVA, não o config estático. `allowSelfCreate`, o catálogo de
42
+ * papéis e o TTL do convite saem do MESMO módulo que o console HTML usa
43
+ * (`host/org_policy.ts`: setting da org → setting global → config → default da
44
+ * lib). Ler só o config estático daria uma superfície que diverge da outra na
45
+ * primeira vez que um admin mexesse na setting — e divergência entre o form e
46
+ * o espelho é exatamente o bug que este controller não pode ter.
47
+ */
48
+ import { createHash } from 'node:crypto';
49
+ import '../augmentations.js';
50
+ import { supportsOrganizations } from '../../accounts/account_store.js';
51
+ import { accountPath } from '../account_paths.js';
52
+ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
53
+ import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
54
+ import { sendOrgInvitationEmail } from '../default_mailer.js';
55
+ // Política efetiva: o MESMO módulo que o console HTML usa. Duas cópias da
56
+ // resolução são como o espelho JSON acabaria mais frouxo que o formulário.
57
+ import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
58
+ import { authkitOrigin } from '../origin.js';
59
+ import { resolveRuntimeSettings } from '../runtime_settings.js';
60
+ import { isRoleInCatalog } from '../runtime_toggles.js';
61
+ /** Erro JSON padrão — mesmo envelope do `account_api_controller`. */
62
+ function apiErr(code, message) {
63
+ return { error: { code, message } };
64
+ }
65
+ /** Papéis que podem administrar uma org no fluxo member-facing. */
66
+ const MANAGER_ROLES = new Set(['owner', 'admin']);
67
+ /**
68
+ * O erro é uma violação de UNICIDADE do banco (slug já existe), e não uma falha
69
+ * de infraestrutura?
70
+ *
71
+ * A distinção importa: mapear TODA exceção do `createOrg` para `409 slug_taken`
72
+ * — como o form e o caminho admin fazem, porque lá o desfecho é um redirect sem
73
+ * mensagem — diria "escolha outro slug" para um banco fora do ar. O host
74
+ * tentaria slug após slug sem nunca conseguir, e o 409 esconderia a
75
+ * indisponibilidade do monitoramento. Aqui, só a violação reconhecida vira 409;
76
+ * qualquer outra coisa sobe e o handler de erro do app responde 500.
77
+ *
78
+ * Os códigos cobrem os drivers que o Lucid suporta. Um store customizado que
79
+ * lance outra coisa cai no caminho "não reconhecido" — que é o conservador: um
80
+ * 500 espúrio é ruído, um 409 espúrio é uma mentira sobre o estado do sistema.
81
+ */
82
+ function isUniqueViolation(err) {
83
+ const code = err?.code;
84
+ if (typeof code === 'string') {
85
+ // pg: unique_violation. mysql/mariadb: ER_DUP_ENTRY. sqlite: os códigos
86
+ // ESTENDIDOS da família de unicidade — e só eles. `SQLITE_CONSTRAINT` cru
87
+ // cobre também NOTNULL, CHECK e FOREIGNKEY, que não são "slug repetido".
88
+ if (code === '23505' ||
89
+ code === 'ER_DUP_ENTRY' ||
90
+ code === 'SQLITE_CONSTRAINT_UNIQUE' ||
91
+ code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
92
+ return true;
93
+ }
94
+ // Drivers sqlite antigos reportam só o código genérico e põem a natureza da
95
+ // violação na mensagem. Checar a mensagem é frágil, então vale apenas
96
+ // ESTREITADO a esse caso: sem ele, um sqlite legado devolveria 500 na
97
+ // corrida; com ele, nenhum outro driver muda de comportamento.
98
+ if (code === 'SQLITE_CONSTRAINT') {
99
+ const message = err?.message;
100
+ return typeof message === 'string' && /UNIQUE constraint failed/i.test(message);
101
+ }
102
+ }
103
+ const errno = err?.errno;
104
+ // mysql/mariadb (1062) e mssql (2627 unique constraint, 2601 unique index).
105
+ return errno === 1062 || errno === 2627 || errno === 2601;
106
+ }
107
+ export default class AccountOrgsApiController {
108
+ // ─── POST /account/api/orgs ──────────────────────────────────────────────
109
+ /** Criar uma org. Exige `allowSelfCreate` na política efetiva. */
110
+ async createOrg(ctx) {
111
+ const c = await this.#context(ctx);
112
+ if (!c)
113
+ return;
114
+ const policy = await effectiveOrgPolicy(ctx, c.cfg);
115
+ if (!policy.allowSelfCreate) {
116
+ return ctx.response.forbidden(apiErr('self_create_disabled', 'Organization self-service creation is off.'));
117
+ }
118
+ const name = String(ctx.request.input('name', '') ?? '').trim();
119
+ const slug = String(ctx.request.input('slug', '') ?? '').trim();
120
+ if (!name || !slug) {
121
+ return ctx.response.badRequest(apiErr('invalid_input', 'Both `name` and `slug` are required.'));
122
+ }
123
+ // Pré-checagem determinística do slug: dá o 409 pelo caminho normal, sem
124
+ // depender de reconhecer o erro do driver. O form engole a falha e
125
+ // redireciona; aqui a tela do host precisa poder dizer "esse slug já
126
+ // existe" em vez de recarregar sem explicação.
127
+ if (await c.store.findOrgBySlug(slug)) {
128
+ return ctx.response.conflict(apiErr('slug_taken', 'Slug already in use.'));
129
+ }
130
+ let org;
131
+ try {
132
+ org = await c.store.createOrg({ name, slug, ownerAccountId: c.accountId });
133
+ }
134
+ catch (err) {
135
+ // Corrida entre a pré-checagem e o insert: o índice único é quem decide.
136
+ if (isUniqueViolation(err)) {
137
+ return ctx.response.conflict(apiErr('slug_taken', 'Slug already in use.'));
138
+ }
139
+ // Banco fora do ar, timeout, constraint de outra natureza: NÃO é "escolha
140
+ // outro slug". Sobe para o handler de erro do app (500).
141
+ throw err;
142
+ }
143
+ await c.cfg.audit?.record({
144
+ type: 'organization.created',
145
+ accountId: c.accountId,
146
+ orgId: org.id,
147
+ metadata: { slug },
148
+ });
149
+ ctx.response.status(201);
150
+ return {
151
+ id: org.id,
152
+ name: org.name,
153
+ slug: org.slug,
154
+ logoUrl: org.logoUrl ?? null,
155
+ role: 'owner',
156
+ };
157
+ }
158
+ // ─── POST /account/api/orgs/:id/activate ────────────────────────────────
159
+ /** Define a org ativa (cookie `authkit_active_org`). Valida membership. */
160
+ async activateOrg(ctx) {
161
+ const c = await this.#context(ctx);
162
+ if (!c)
163
+ return;
164
+ const orgId = String(ctx.request.param('id'));
165
+ const membership = await c.store.getOrgMembership(orgId, c.accountId);
166
+ // Não-membro e org inexistente respondem IGUAL (404 sem detalhe): distinguir
167
+ // as duas vazaria a existência de orgs de terceiros.
168
+ if (!membership) {
169
+ return ctx.response.notFound(apiErr('not_found', 'Organization not found or not a member.'));
170
+ }
171
+ const org = await c.store.findOrgById(orgId);
172
+ if (!org) {
173
+ return ctx.response.notFound(apiErr('not_found', 'Organization not found or not a member.'));
174
+ }
175
+ ctx.response.cookie(ACTIVE_ORG_COOKIE, encodeActiveOrgCookie({ orgId, orgSlug: org.slug, orgRole: membership.role }), {
176
+ httpOnly: true,
177
+ sameSite: 'lax',
178
+ maxAge: ACTIVE_ORG_COOKIE_TTL,
179
+ secure: ctx.request.secure(),
180
+ path: '/',
181
+ });
182
+ await c.cfg.audit?.record({
183
+ type: 'organization.switched',
184
+ accountId: c.accountId,
185
+ orgId,
186
+ metadata: { orgId, orgSlug: org.slug },
187
+ });
188
+ return { ok: true, activeOrgId: orgId, slug: org.slug, role: membership.role };
189
+ }
190
+ // ─── POST /account/api/orgs/deactivate ──────────────────────────────────
191
+ /** Limpa a org ativa. Não depende de membership (só apaga o cookie). */
192
+ async deactivateOrg(ctx) {
193
+ const service = await ctx.containerResolver.make('authkit.server');
194
+ const cfg = service.config;
195
+ const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
196
+ ctx.response.clearCookie(ACTIVE_ORG_COOKIE, { path: '/' });
197
+ await cfg.audit?.record({ type: 'organization.deactivated', accountId });
198
+ return { ok: true, activeOrgId: null };
199
+ }
200
+ // ─── POST /account/api/orgs/:id/leave ───────────────────────────────────
201
+ /** Sai da org. O store recusa o último owner (`reason: 'last_owner'`). */
202
+ async leaveOrg(ctx) {
203
+ const c = await this.#context(ctx);
204
+ if (!c)
205
+ return;
206
+ const orgId = String(ctx.request.param('id'));
207
+ const result = await c.store.removeOrgMember(orgId, c.accountId);
208
+ if (!result.ok) {
209
+ if (result.reason === 'last_owner') {
210
+ return ctx.response
211
+ .status(409)
212
+ .send(apiErr('last_owner', 'The last owner cannot leave the organization.'));
213
+ }
214
+ return ctx.response.notFound(apiErr('not_found', 'Membership not found.'));
215
+ }
216
+ await c.cfg.audit?.record({
217
+ type: 'organization.member_removed',
218
+ accountId: c.accountId,
219
+ orgId,
220
+ metadata: { orgId, self: true },
221
+ });
222
+ return { ok: true, orgId };
223
+ }
224
+ // ─── POST /account/api/orgs/:id/invitations ─────────────────────────────
225
+ /** Convida alguém por e-mail. Exige owner/admin; papel validado no catálogo. */
226
+ async inviteMember(ctx) {
227
+ const c = await this.#context(ctx);
228
+ if (!c)
229
+ return;
230
+ const orgId = String(ctx.request.param('id'));
231
+ const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
232
+ if (!membership)
233
+ return;
234
+ const email = String(ctx.request.input('email', '') ?? '').trim();
235
+ const role = String(ctx.request.input('role', 'member') ?? 'member').trim();
236
+ if (!email) {
237
+ return ctx.response.badRequest(apiErr('invalid_input', '`email` is required.'));
238
+ }
239
+ const settings = await resolveRuntimeSettings(ctx);
240
+ if (!(await isRoleInCatalog(role, settings, orgPolicyDefaults(c.cfg), orgId))) {
241
+ return ctx.response.unprocessableEntity(apiErr('invalid_role', 'Role inválida.'));
242
+ }
243
+ // Só um OWNER concede `owner` — um admin tentando isso é escalonamento.
244
+ if (role === 'owner' && membership.role !== 'owner') {
245
+ return ctx.response.forbidden(apiErr('forbidden', 'Only an owner can grant the owner role.'));
246
+ }
247
+ const policy = await effectiveOrgPolicy(ctx, c.cfg, orgId);
248
+ const { invitation, token } = await c.store.createOrgInvitation({
249
+ organizationId: orgId,
250
+ email,
251
+ role,
252
+ invitedBy: c.accountId,
253
+ ttlHours: policy.invitationTtlHours,
254
+ });
255
+ // Entrega best-effort, igual ao form: um e-mail que não sai NÃO desfaz o
256
+ // convite (ele continua aceitável pela lista de convites do convidado).
257
+ try {
258
+ const org = await c.store.findOrgById(orgId);
259
+ const acceptUrl = `${authkitOrigin(c.cfg)}${accountPath('orgs')}/invitations/${token}/accept`;
260
+ const payload = {
261
+ email,
262
+ invitationId: invitation.id,
263
+ orgName: org?.name ?? orgId,
264
+ orgSlug: org?.slug ?? orgId,
265
+ role,
266
+ acceptUrl,
267
+ token,
268
+ };
269
+ if (c.cfg.mail?.onOrgInvitation)
270
+ await c.cfg.mail.onOrgInvitation(payload);
271
+ else
272
+ await sendOrgInvitationEmail(ctx, payload);
273
+ }
274
+ catch {
275
+ /* best-effort */
276
+ }
277
+ await c.cfg.audit?.record({
278
+ type: 'organization.invitation_sent',
279
+ accountId: c.accountId,
280
+ orgId,
281
+ metadata: { orgId, email, role },
282
+ });
283
+ ctx.response.status(201);
284
+ return {
285
+ id: invitation.id,
286
+ organizationId: orgId,
287
+ email: invitation.email ?? email,
288
+ role,
289
+ expiresAt: invitation.expiresAt ?? null,
290
+ createdAt: invitation.createdAt ?? null,
291
+ // O TOKEN NÃO VOLTA. Ele é a credencial de aceite e viaja por e-mail; um
292
+ // admin que o lesse na resposta entraria na org como o convidado.
293
+ };
294
+ }
295
+ // ─── DELETE /account/api/orgs/:id/invitations/:invId ────────────────────
296
+ /** Revoga um convite pendente. Escopado por org (anti-IDOR), como o form. */
297
+ async revokeInvitation(ctx) {
298
+ const c = await this.#context(ctx);
299
+ if (!c)
300
+ return;
301
+ const orgId = String(ctx.request.param('id'));
302
+ const invId = String(ctx.request.param('invId'));
303
+ const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
304
+ if (!membership)
305
+ return;
306
+ const revoked = await c.store.revokeInvitation(orgId, invId);
307
+ if (!revoked) {
308
+ return ctx.response.notFound(apiErr('not_found', 'Invitation not found in this organization.'));
309
+ }
310
+ await c.cfg.audit?.record({
311
+ type: 'organization.invitation_revoked',
312
+ actorId: c.accountId,
313
+ orgId,
314
+ metadata: { orgId, invitationId: invId },
315
+ });
316
+ return { ok: true, revoked: invId };
317
+ }
318
+ // ─── DELETE /account/api/orgs/:id/members/:accountId ────────────────────
319
+ /** Remove um membro. Exige owner/admin na org. */
320
+ async removeMember(ctx) {
321
+ const c = await this.#context(ctx);
322
+ if (!c)
323
+ return;
324
+ const orgId = String(ctx.request.param('id'));
325
+ const targetId = String(ctx.request.param('accountId'));
326
+ const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
327
+ if (!membership)
328
+ return;
329
+ const result = await c.store.removeOrgMember(orgId, targetId);
330
+ if (!result.ok) {
331
+ if (result.reason === 'last_owner') {
332
+ return ctx.response
333
+ .status(409)
334
+ .send(apiErr('last_owner', 'The last owner cannot be removed.'));
335
+ }
336
+ return ctx.response.notFound(apiErr('not_found', 'Member not found in this organization.'));
337
+ }
338
+ await c.cfg.audit?.record({
339
+ type: 'organization.member_removed',
340
+ actorId: c.accountId,
341
+ orgId,
342
+ metadata: { orgId, targetAccountId: targetId },
343
+ });
344
+ return { ok: true, orgId, accountId: targetId };
345
+ }
346
+ // ─── PATCH /account/api/orgs/:id/members/:accountId ─────────────────────
347
+ /**
348
+ * Troca o papel de um membro.
349
+ *
350
+ * Não existe equivalente member-facing em formulário (o console HTML só
351
+ * troca papel pelo caminho ADMIN). As regras vieram, então, da interseção
352
+ * das duas superfícies que já existem: o guard de owner/admin + catálogo de
353
+ * papéis do `invite` member-facing, e a checagem de `last_owner` do
354
+ * `AdminOrgsService.updateMemberRole`. Conceder `owner` continua privativo
355
+ * de um owner.
356
+ */
357
+ async updateMemberRole(ctx) {
358
+ const c = await this.#context(ctx);
359
+ if (!c)
360
+ return;
361
+ const orgId = String(ctx.request.param('id'));
362
+ const targetId = String(ctx.request.param('accountId'));
363
+ const membership = await this.#requireManager(ctx, c.store, orgId, c.accountId);
364
+ if (!membership)
365
+ return;
366
+ const role = String(ctx.request.input('role', '') ?? '').trim();
367
+ if (!role) {
368
+ return ctx.response.badRequest(apiErr('invalid_input', '`role` is required.'));
369
+ }
370
+ const settings = await resolveRuntimeSettings(ctx);
371
+ if (!(await isRoleInCatalog(role, settings, orgPolicyDefaults(c.cfg), orgId))) {
372
+ return ctx.response.unprocessableEntity(apiErr('invalid_role', 'Role inválida.'));
373
+ }
374
+ if (role === 'owner' && membership.role !== 'owner') {
375
+ return ctx.response.forbidden(apiErr('forbidden', 'Only an owner can grant the owner role.'));
376
+ }
377
+ const result = await c.store.updateOrgMemberRole(orgId, targetId, role);
378
+ if (!result.ok) {
379
+ if (result.reason === 'last_owner') {
380
+ return ctx.response
381
+ .status(409)
382
+ .send(apiErr('last_owner', 'The last owner cannot be demoted.'));
383
+ }
384
+ return ctx.response.notFound(apiErr('not_found', 'Member not found in this organization.'));
385
+ }
386
+ await c.cfg.audit?.record({
387
+ type: 'organization.member_role_updated',
388
+ actorId: c.accountId,
389
+ orgId,
390
+ metadata: { orgId, targetAccountId: targetId, role },
391
+ });
392
+ return { ok: true, orgId, accountId: targetId, role };
393
+ }
394
+ // ─── POST /account/api/orgs/invitations/:token/accept ───────────────────
395
+ /**
396
+ * Aceita um convite pelo token do e-mail.
397
+ *
398
+ * Diferente do form (montado FORA do guard para tratar o não-autenticado com
399
+ * um redirect para o login), esta rota vive DENTRO do `accountGuard`: uma
400
+ * tela SPA já está logada, e a resposta a um visitante anônimo aqui teria de
401
+ * ser 401 em JSON, não uma navegação.
402
+ */
403
+ async acceptInvitation(ctx) {
404
+ const c = await this.#context(ctx);
405
+ if (!c)
406
+ return;
407
+ const token = String(ctx.request.param('token') ?? '');
408
+ const tokenHash = createHash('sha256').update(token).digest('hex');
409
+ const invitation = await c.store.findInvitationByTokenHash(tokenHash);
410
+ if (!invitation) {
411
+ return ctx.response.notFound(apiErr('not_found', 'Invitation not found.'));
412
+ }
413
+ const result = await c.store.acceptInvitation(invitation.id, c.accountId);
414
+ if (!result.ok) {
415
+ // Os motivos vêm do store e são os MESMOS que o form recebe (e descarta
416
+ // no redirect): o caminho JSON só deixa de escondê-los.
417
+ const status = result.reason === 'expired' ? 410 : result.reason === 'email_mismatch' ? 403 : 409;
418
+ return ctx.response
419
+ .status(status)
420
+ .send(apiErr(result.reason ?? 'accept_failed', 'Invitation could not be accepted.'));
421
+ }
422
+ await c.cfg.audit?.record({
423
+ type: 'organization.invitation_accepted',
424
+ accountId: c.accountId,
425
+ orgId: invitation.organizationId,
426
+ metadata: { orgId: invitation.organizationId, invitationId: invitation.id },
427
+ });
428
+ return { ok: true, organizationId: invitation.organizationId, role: invitation.role };
429
+ }
430
+ // ─── Internos ───────────────────────────────────────────────────────────
431
+ /**
432
+ * Resolve config + store (com orgs) + a conta da sessão. Quando o
433
+ * pré-requisito falha, JÁ RESPONDE e devolve `null` — o caller só precisa de
434
+ * `if (!c) return`.
435
+ */
436
+ async #context(ctx) {
437
+ const service = await ctx.containerResolver.make('authkit.server');
438
+ const cfg = service.config;
439
+ const store = cfg.accountStore;
440
+ if (!supportsOrganizations(store)) {
441
+ ctx.response.notFound(apiErr('not_supported', 'Organizations not supported.'));
442
+ return null;
443
+ }
444
+ const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
445
+ if (!accountId) {
446
+ ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
447
+ return null;
448
+ }
449
+ return { cfg, store, accountId };
450
+ }
451
+ /**
452
+ * Exige que o ator seja owner/admin NA ORG DO PATH. Responde 403 e devolve
453
+ * `null` quando não é.
454
+ *
455
+ * 403 e não 404: as duas negativas — "é membro sem poder" e "não é membro" —
456
+ * saem com o MESMO status, então a resposta não diz a um estranho se a org
457
+ * existe. Saber que lhe falta papel numa org da qual já é membro não lhe
458
+ * conta nada novo.
459
+ */
460
+ async #requireManager(ctx, store, orgId, actorId) {
461
+ const membership = await store.getOrgMembership(orgId, actorId);
462
+ if (!membership || !MANAGER_ROLES.has(membership.role)) {
463
+ ctx.response.forbidden(apiErr('forbidden', 'Owner or admin role required.'));
464
+ return null;
465
+ }
466
+ return membership;
467
+ }
468
+ }
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { normalizeEmailIdentifier } from './email_identifier.js';
9
10
  let limiterServicePromise;
10
11
  /**
11
12
  * Importa o service do limiter do HOST de forma preguiçosa e fail-safe (mesmo
@@ -32,9 +33,13 @@ export function __setLockoutLimiterLoaderForTests(fn) {
32
33
  limiterServicePromise = undefined;
33
34
  }
34
35
  }
35
- /** Normaliza o email para virar chave estável (lowercase + trim). */
36
+ /**
37
+ * Normaliza o email para virar chave estável. Delega à normalização ÚNICA da
38
+ * identidade ({@link normalizeEmailIdentifier}) para que a chave do lockout e a
39
+ * do lookup da conta nunca divirjam.
40
+ */
36
41
  function normalizeEmail(email) {
37
- return (email ?? '').trim().toLowerCase();
42
+ return normalizeEmailIdentifier(email);
38
43
  }
39
44
  /**
40
45
  * Backoff progressivo PURO (sem I/O — fácil de testar): a duração do lock cresce
@@ -2,7 +2,7 @@ import { supportsOrganizations } from '../../accounts/account_store.js';
2
2
  import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../../pagination.js';
3
3
  import { accountPath } from '../account_paths.js';
4
4
  import { sendOrgInvitationEmail } from '../default_mailer.js';
5
- import { isRoleInCatalog, resolveRoleCatalogList } from '../runtime_toggles.js';
5
+ import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, resolveRoleCatalogList, } from '../runtime_toggles.js';
6
6
  /**
7
7
  * Lógica de gestão de organizações compartilhada entre o console admin (HTML)
8
8
  * e a Admin REST API (JSON). Usada pelos `AdminOrgsController` e `ApiOrgsController`.
@@ -278,7 +278,8 @@ export class AdminOrgsService {
278
278
  email: input.email,
279
279
  role: input.role,
280
280
  invitedBy: actor.actorId ?? 'admin',
281
- ttlHours: this.cfg.organizations.invitationTtlHours,
281
+ // TTL da política efetiva da org (setting org → global → config).
282
+ ttlHours: (await resolveEffectiveOrganizationsPolicy(settings, this.orgPolicyConfigDefaults(), orgId)).invitationTtlHours,
282
283
  });
283
284
  // Sends the invitation email (best-effort). The host hook wins when present;
284
285
  // otherwise the host-kit sends its own branded/translated email, like every
@@ -4,6 +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, resolveEmailIdentifier } from '../email_identifier.js';
7
8
  import { authkitOrigin } from '../origin.js';
8
9
  import { resolveEffectiveRolesCatalog } from '../runtime_toggles.js';
9
10
  /**
@@ -24,7 +25,16 @@ export class AdminUsersService {
24
25
  */
25
26
  async create(ctx, input, actor) {
26
27
  const store = this.cfg.accountStore;
27
- const existing = await store.findByEmail(input.email);
28
+ // MESMA normalização do cadastro/login. Os validators já a aplicam, mas o
29
+ // serviço também é chamado direto (console/API/host) — normaliza aqui para
30
+ // que nenhum caminho grave um endereço que o login não encontra.
31
+ const email = normalizeEmailIdentifier(input.email);
32
+ // Duplicado: enxerga também a conta gravada com o endereço mutilado pelo
33
+ // cadastro antigo (ponte legada) — senão o admin criaria uma SEGUNDA conta
34
+ // para quem o cadastro público recusaria com `email_taken`.
35
+ const existing = (await resolveEmailIdentifier(store, input.email, {
36
+ legacyFallback: this.cfg.login?.legacyEmailFallback ?? true,
37
+ })).account;
28
38
  if (existing)
29
39
  return { ok: false, reason: 'email_taken' };
30
40
  const hasPassword = !!input.password;
@@ -32,7 +42,7 @@ export class AdminUsersService {
32
42
  let account;
33
43
  try {
34
44
  account = await store.create({
35
- email: input.email,
45
+ email,
36
46
  password: initialPassword,
37
47
  fullName: input.name ?? null,
38
48
  });
@@ -52,7 +62,7 @@ export class AdminUsersService {
52
62
  await this.cfg.audit?.record({
53
63
  type: 'user.created',
54
64
  accountId: account.id,
55
- email: input.email,
65
+ email: account.email,
56
66
  actorId: actor.actorId,
57
67
  ip: actor.ip,
58
68
  metadata: {
@@ -48,7 +48,7 @@ export declare function grantDto(grant: AdminGrant): {
48
48
  };
49
49
  export declare function auditDto(event: StoredAuditEvent): {
50
50
  id: string;
51
- type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
51
+ type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "bot_protection.rejected" | "client.created" | "client.deleted" | "client.secret_regenerated" | "client.updated" | "email.change_requested" | "email.changed" | "email_change.cancelled" | "email_change.confirmed" | "email_change.requested" | "email_verification.consumed" | "email_verification.issued" | "grant.revoked_by_user" | "impersonation" | "impersonation.panel_viewed" | "impersonation.stopped" | "keys.rotated" | "login.failure" | "login.magic_link_sent" | "login.new_device" | "login.new_ip_notified" | "login.otp_failed" | "login.otp_invalidated" | "login.otp_sent" | "login.otp_verified" | "login.success" | "maintenance.disabled" | "maintenance.enabled" | "mfa.disabled" | "mfa.enabled" | "mfa.recovery_codes_regenerated" | "organization.created" | "organization.deactivated" | "organization.deleted" | "organization.invitation_accepted" | "organization.invitation_revoked" | "organization.invitation_sent" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.updated" | "otp.locked" | "otp.unlock_failed" | "otp.unlocked" | "passkey.registered" | "passkey.removed" | "password.changed" | "password.expired_change_forced" | "password.rehashed" | "password_reset.consumed" | "password_reset.issued" | "pat.issued" | "pat.revoked" | "pat.used" | "profile.updated" | "roles_catalog.updated" | "security_notice.sent" | "session.revoked" | "session.revoked_all" | "session.single_enforced" | "settings.updated" | "signup" | "sudo.confirmed" | "trusted_device.revoked" | "user.created" | "user.deleted" | "user.disabled" | "user.enabled" | "user.password_reset_sent";
52
52
  accountId: string | null;
53
53
  email: string | null;
54
54
  clientId: string | null;
@@ -69,7 +69,7 @@ export declare function clientPartialInput(v: ClientInputFields): Partial<Client
69
69
  * `minLength` — seria duplicar/contornar a policy configurável do projeto.
70
70
  */
71
71
  export declare const adminUserCreateValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
72
- email: import("@vinejs/vine").VineString;
72
+ email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
73
73
  name: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
74
74
  password: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
75
75
  invite: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineBoolean>;
@@ -179,7 +179,7 @@ export declare const orgMemberRoleValidator: import("@vinejs/vine").VineValidato
179
179
  }>, Record<string, any> | undefined>;
180
180
  /** Criação de convite: e-mail obrigatório e validado; role opcional (default `member`). */
181
181
  export declare const orgInvitationValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
182
- email: import("@vinejs/vine").VineString;
182
+ email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
183
183
  role: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
184
184
  }, {
185
185
  email: string;
@@ -1,4 +1,5 @@
1
1
  import vine from '@vinejs/vine';
2
+ import { normalizeEmailIdentifier } from './email_identifier.js';
2
3
  /**
3
4
  * Validators VineJS dos recursos administrativos (Admin REST API + console admin).
4
5
  *
@@ -123,7 +124,7 @@ export function clientPartialInput(v) {
123
124
  * `minLength` — seria duplicar/contornar a policy configurável do projeto.
124
125
  */
125
126
  export const adminUserCreateValidator = vine.compile(vine.object({
126
- email: vine.string().trim().email(),
127
+ email: vine.string().trim().email().transform(normalizeEmailIdentifier),
127
128
  name: vine.string().trim().maxLength(255).optional(),
128
129
  password: vine.string().maxLength(255).optional(),
129
130
  invite: vine.boolean().optional(),
@@ -162,7 +163,7 @@ export const orgMemberRoleValidator = vine.compile(vine.object({
162
163
  }));
163
164
  /** Criação de convite: e-mail obrigatório e validado; role opcional (default `member`). */
164
165
  export const orgInvitationValidator = vine.compile(vine.object({
165
- email: vine.string().trim().email(),
166
+ email: vine.string().trim().email().transform(normalizeEmailIdentifier),
166
167
  role: vine.string().trim().minLength(1).optional(),
167
168
  }));
168
169
  // ─── Catálogo de roles ──────────────────────────────────────────────────────
@@ -4,11 +4,10 @@ import { supportsPasskeys } from '../../accounts/account_store.js';
4
4
  import { accountPath } from '../account_paths.js';
5
5
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
6
6
  import { translate } from '../i18n.js';
7
+ import { PASSKEY_REG_CHALLENGE_KEY } from '../passkey_registration_challenge.js';
7
8
  import { resolveRuntimeSettings } from '../runtime_settings.js';
8
9
  import { dispatchSecurityNotice } from '../security_notice_service.js';
9
10
  import { requireSudo } from '../sudo_mode.js';
10
- /** Desafio WebAuthn pendente (registro) guardado na sessão entre begin/finish. */
11
- const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';
12
11
  /**
13
12
  * `true` quando a requisição é uma NAVEGAÇÃO (form/browser) e não um XHR/fetch:
14
13
  * o cliente aceita `text/html` e NÃO pede explicitamente `application/json`.