@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.
- package/build/commands/import_users.js +6 -1
- package/build/index.d.ts +5 -1
- package/build/index.js +7 -1
- package/build/src/accounts/account_store.d.ts +40 -0
- package/build/src/accounts/account_store.js +20 -0
- package/build/src/accounts/lucid_store/mfa.js +22 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +4 -0
- package/build/src/commands/import_users.d.ts +7 -0
- package/build/src/commands/import_users.js +15 -3
- package/build/src/define_config.d.ts +81 -0
- package/build/src/define_config.js +18 -3
- package/build/src/host/account_api/account_api_controller.d.ts +2 -0
- package/build/src/host/account_api/account_api_controller.js +22 -3
- package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
- package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
- package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
- package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
- package/build/src/host/account_lockout.js +7 -2
- package/build/src/host/admin_api/admin_orgs_service.js +3 -2
- package/build/src/host/admin_api/admin_users_service.js +13 -3
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_validators.d.ts +2 -2
- package/build/src/host/admin_validators.js +3 -2
- package/build/src/host/controllers/account_mfa_controller.js +1 -2
- package/build/src/host/controllers/account_orgs_controller.js +19 -11
- package/build/src/host/controllers/account_security_controller.js +3 -1
- package/build/src/host/controllers/account_session_controller.js +17 -6
- package/build/src/host/controllers/interaction_controller.d.ts +10 -0
- package/build/src/host/controllers/interaction_controller.js +93 -34
- package/build/src/host/controllers/registration_controller.js +35 -9
- package/build/src/host/controllers/social_controller.js +11 -2
- package/build/src/host/email_identifier.d.ts +92 -0
- package/build/src/host/email_identifier.js +234 -0
- package/build/src/host/idp_session_bridge.d.ts +55 -0
- package/build/src/host/idp_session_bridge.js +108 -0
- package/build/src/host/middleware/account_auth.js +3 -2
- package/build/src/host/org_policy.d.ts +26 -0
- package/build/src/host/org_policy.js +35 -0
- package/build/src/host/passkey_registration_challenge.d.ts +12 -0
- package/build/src/host/passkey_registration_challenge.js +12 -0
- package/build/src/host/register_auth_host.js +56 -1
- package/build/src/host/runtime_toggles.d.ts +2 -2
- package/build/src/host/runtime_toggles.js +3 -1
- package/build/src/host/sudo_mode.d.ts +17 -0
- package/build/src/host/sudo_mode.js +31 -12
- package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/host/validators.d.ts +5 -5
- package/build/src/host/validators.js +17 -5
- package/build/src/provider/build_provider.js +46 -14
- package/build/src/provider/registration_policy.d.ts +99 -0
- package/build/src/provider/registration_policy.js +229 -0
- package/package.json +2 -2
|
@@ -0,0 +1,286 @@
|
|
|
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 QRCode from 'qrcode';
|
|
45
|
+
import { supportsMfa, supportsPasskeys, supportsRecoveryCodeRegeneration, } from '../../accounts/account_store.js';
|
|
46
|
+
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
47
|
+
import { translate } from '../i18n.js';
|
|
48
|
+
import { PASSKEY_REG_CHALLENGE_KEY } from '../passkey_registration_challenge.js';
|
|
49
|
+
import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
50
|
+
import { dispatchSecurityNotice } from '../security_notice_service.js';
|
|
51
|
+
import { isSudoSatisfied } from '../sudo_mode.js';
|
|
52
|
+
/** Erro JSON padrão — mesmo envelope do `account_api_controller`. */
|
|
53
|
+
function apiErr(code, message) {
|
|
54
|
+
return { error: { code, message } };
|
|
55
|
+
}
|
|
56
|
+
export default class AccountMfaApiController {
|
|
57
|
+
// ─── POST /account/api/mfa/totp/enroll ──────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Inicia o enrolamento TOTP: segredo pendente + `otpauth://` URI + o QR já
|
|
60
|
+
* renderizado como data-URL (o console renderiza server-side; aqui a tela do
|
|
61
|
+
* host recebe pronto e decide se mostra o QR, o segredo, ou os dois).
|
|
62
|
+
*/
|
|
63
|
+
async enrollTotp(ctx) {
|
|
64
|
+
const c = await this.#mfaContext(ctx);
|
|
65
|
+
if (!c)
|
|
66
|
+
return;
|
|
67
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
68
|
+
return;
|
|
69
|
+
const started = await c.store.startTotpEnrollment(c.userId);
|
|
70
|
+
if (!started) {
|
|
71
|
+
return ctx.response
|
|
72
|
+
.status(422)
|
|
73
|
+
.send(apiErr('enroll_failed', 'Could not start TOTP enrollment.'));
|
|
74
|
+
}
|
|
75
|
+
const qrDataUrl = await QRCode.toDataURL(started.otpauthUri);
|
|
76
|
+
return { secret: started.secret, otpauthUri: started.otpauthUri, qrDataUrl };
|
|
77
|
+
}
|
|
78
|
+
// ─── POST /account/api/mfa/totp/confirm ─────────────────────────────────
|
|
79
|
+
/**
|
|
80
|
+
* Confirma o enrolamento com o código do app autenticador. Sucesso ativa o
|
|
81
|
+
* MFA e devolve os recovery codes — UMA vez, como no console (lá eles vão num
|
|
82
|
+
* flash; aqui, no corpo da resposta, que é o equivalente headless).
|
|
83
|
+
*/
|
|
84
|
+
async confirmTotp(ctx) {
|
|
85
|
+
const c = await this.#mfaContext(ctx);
|
|
86
|
+
if (!c)
|
|
87
|
+
return;
|
|
88
|
+
const code = String(ctx.request.input('code', '') ?? '').trim();
|
|
89
|
+
const result = await c.store.confirmTotpEnrollment(c.userId, code);
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
// NÃO regenera o segredo pendente: o usuário já escaneou o QR, e um
|
|
92
|
+
// segredo novo invalidaria o app autenticador dele. Mesma decisão do
|
|
93
|
+
// console — a tela pede outro código sobre o MESMO segredo.
|
|
94
|
+
return ctx.response
|
|
95
|
+
.status(422)
|
|
96
|
+
.send(apiErr('invalid_code', translate(c.cfg.messages, 'errors.invalid_code')));
|
|
97
|
+
}
|
|
98
|
+
await c.cfg.audit?.record({
|
|
99
|
+
type: 'mfa.enabled',
|
|
100
|
+
accountId: c.userId,
|
|
101
|
+
ip: ctx.request.ip?.() ?? null,
|
|
102
|
+
metadata: { method: 'totp' },
|
|
103
|
+
});
|
|
104
|
+
await this.#notice(ctx, c, 'mfa_enabled');
|
|
105
|
+
return { ok: true, enabled: true, recoveryCodes: result.recoveryCodes ?? [] };
|
|
106
|
+
}
|
|
107
|
+
// ─── POST /account/api/mfa/totp/disable ─────────────────────────────────
|
|
108
|
+
/** Desliga o MFA (TOTP + recovery codes). Exige sudo, como o console. */
|
|
109
|
+
async disableTotp(ctx) {
|
|
110
|
+
const c = await this.#mfaContext(ctx);
|
|
111
|
+
if (!c)
|
|
112
|
+
return;
|
|
113
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
114
|
+
return;
|
|
115
|
+
await c.store.disableMfa(c.userId);
|
|
116
|
+
await c.cfg.audit?.record({
|
|
117
|
+
type: 'mfa.disabled',
|
|
118
|
+
accountId: c.userId,
|
|
119
|
+
ip: ctx.request.ip?.() ?? null,
|
|
120
|
+
});
|
|
121
|
+
await this.#notice(ctx, c, 'mfa_disabled');
|
|
122
|
+
return { ok: true, enabled: false };
|
|
123
|
+
}
|
|
124
|
+
// ─── POST /account/api/mfa/recovery-codes ───────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* Regenera os recovery codes de uma conta com MFA ATIVO e devolve os novos —
|
|
127
|
+
* uma única vez. Exige sudo: o resultado é um conjunto de credenciais que
|
|
128
|
+
* contorna o segundo fator, então vale o mesmo gate do `enroll`/`disable`.
|
|
129
|
+
*
|
|
130
|
+
* Capability-probed: stores que não implementam
|
|
131
|
+
* `regenerateRecoveryCodes` respondem 422 em vez de 500.
|
|
132
|
+
*/
|
|
133
|
+
async regenerateRecoveryCodes(ctx) {
|
|
134
|
+
const c = await this.#mfaContext(ctx);
|
|
135
|
+
if (!c)
|
|
136
|
+
return;
|
|
137
|
+
if (!supportsRecoveryCodeRegeneration(c.store)) {
|
|
138
|
+
return ctx.response
|
|
139
|
+
.status(422)
|
|
140
|
+
.send(apiErr('capability_unsupported', 'Recovery code regeneration not supported.'));
|
|
141
|
+
}
|
|
142
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
143
|
+
return;
|
|
144
|
+
const codes = await c.store.regenerateRecoveryCodes(c.userId);
|
|
145
|
+
if (!codes) {
|
|
146
|
+
// Sem MFA ativo não há conjunto a regenerar — enrolar é o caminho.
|
|
147
|
+
return ctx.response.status(422).send(apiErr('mfa_not_enabled', 'MFA is not enabled.'));
|
|
148
|
+
}
|
|
149
|
+
await c.cfg.audit?.record({
|
|
150
|
+
type: 'mfa.recovery_codes_regenerated',
|
|
151
|
+
accountId: c.userId,
|
|
152
|
+
ip: ctx.request.ip?.() ?? null,
|
|
153
|
+
});
|
|
154
|
+
return { ok: true, recoveryCodes: codes };
|
|
155
|
+
}
|
|
156
|
+
// ─── POST /account/api/mfa/passkeys/options ─────────────────────────────
|
|
157
|
+
/**
|
|
158
|
+
* Options da cerimônia de REGISTRO de passkey, guardando o desafio na sessão.
|
|
159
|
+
* Sem sudo, igual ao endpoint clássico: quem paga o gate é o `verify`, que é
|
|
160
|
+
* onde a credencial passa a existir.
|
|
161
|
+
*/
|
|
162
|
+
async passkeyRegisterOptions(ctx) {
|
|
163
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
164
|
+
const cfg = service.config;
|
|
165
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
166
|
+
if (!supportsPasskeys(cfg.accountStore)) {
|
|
167
|
+
return ctx.response
|
|
168
|
+
.status(422)
|
|
169
|
+
.send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
|
|
170
|
+
}
|
|
171
|
+
const generated = await cfg.accountStore.generatePasskeyRegistrationOptions?.(userId);
|
|
172
|
+
if (!generated) {
|
|
173
|
+
return ctx.response
|
|
174
|
+
.status(422)
|
|
175
|
+
.send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
|
|
176
|
+
}
|
|
177
|
+
ctx.session.put(PASSKEY_REG_CHALLENGE_KEY, generated.challenge);
|
|
178
|
+
return generated.options;
|
|
179
|
+
}
|
|
180
|
+
// ─── POST /account/api/mfa/passkeys/verify ──────────────────────────────
|
|
181
|
+
/**
|
|
182
|
+
* Verifica o attestation contra o desafio da sessão e persiste a credencial.
|
|
183
|
+
*
|
|
184
|
+
* O endpoint clássico responde 302 numa navegação e `{ok:true}` num fetch;
|
|
185
|
+
* este responde SEMPRE JSON — inclusive na recusa de sudo, que lá é um
|
|
186
|
+
* redirect. É a diferença que faz a cerimônia caber numa SPA: o browser não
|
|
187
|
+
* pode navegar entre `startRegistration()` e a verificação.
|
|
188
|
+
*/
|
|
189
|
+
async passkeyRegisterVerify(ctx) {
|
|
190
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
191
|
+
const cfg = service.config;
|
|
192
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
193
|
+
if (!supportsPasskeys(cfg.accountStore)) {
|
|
194
|
+
return ctx.response
|
|
195
|
+
.status(422)
|
|
196
|
+
.send(apiErr('capability_unsupported', translate(cfg.messages, 'errors.passkeys_unavailable')));
|
|
197
|
+
}
|
|
198
|
+
if (!(await this.#requireSudoJson(ctx)))
|
|
199
|
+
return;
|
|
200
|
+
const challenge = ctx.session.get(PASSKEY_REG_CHALLENGE_KEY);
|
|
201
|
+
if (!challenge) {
|
|
202
|
+
return ctx.response
|
|
203
|
+
.status(400)
|
|
204
|
+
.send(apiErr('challenge_expired', translate(cfg.messages, 'errors.challenge_expired')));
|
|
205
|
+
}
|
|
206
|
+
const body = ctx.request.input('response', ctx.request.body());
|
|
207
|
+
const ok = (await cfg.accountStore.verifyPasskeyRegistration?.(userId, body, challenge)) ?? false;
|
|
208
|
+
// Queima o desafio em QUALQUER desfecho: um attestation recusado não pode
|
|
209
|
+
// ser retentado contra o mesmo challenge.
|
|
210
|
+
ctx.session.forget(PASSKEY_REG_CHALLENGE_KEY);
|
|
211
|
+
if (!ok) {
|
|
212
|
+
return ctx.response
|
|
213
|
+
.status(400)
|
|
214
|
+
.send(apiErr('invalid_response', translate(cfg.messages, 'errors.invalid_code')));
|
|
215
|
+
}
|
|
216
|
+
const ip = ctx.request.ip?.() ?? null;
|
|
217
|
+
await cfg.audit?.record({
|
|
218
|
+
type: 'mfa.enabled',
|
|
219
|
+
accountId: userId,
|
|
220
|
+
ip,
|
|
221
|
+
metadata: { method: 'webauthn' },
|
|
222
|
+
});
|
|
223
|
+
await cfg.audit?.record({ type: 'passkey.registered', accountId: userId, ip });
|
|
224
|
+
const account = await cfg.accountStore.findById(userId);
|
|
225
|
+
if (account) {
|
|
226
|
+
const ts = new Date().toISOString();
|
|
227
|
+
for (const kind of ['passkey_added', 'mfa_enabled']) {
|
|
228
|
+
await dispatchSecurityNotice(ctx, { account: { id: userId, email: account.email }, kind, ip, timestamp: ts }, cfg.mail, cfg.audit, cfg);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
233
|
+
// ─── Internos ───────────────────────────────────────────────────────────
|
|
234
|
+
/**
|
|
235
|
+
* Resolve config + store com MFA + a conta da sessão. Quando o pré-requisito
|
|
236
|
+
* falha, JÁ RESPONDE e devolve `null`.
|
|
237
|
+
*/
|
|
238
|
+
async #mfaContext(ctx) {
|
|
239
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
240
|
+
const cfg = service.config;
|
|
241
|
+
const store = cfg.accountStore;
|
|
242
|
+
if (!supportsMfa(store)) {
|
|
243
|
+
ctx.response
|
|
244
|
+
.status(422)
|
|
245
|
+
.send(apiErr('capability_unsupported', 'MFA not supported by the account store.'));
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
|
|
249
|
+
if (!userId) {
|
|
250
|
+
ctx.response.unauthorized(apiErr('unauthorized', 'Not authenticated.'));
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return { cfg, store, userId };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Gate de sudo em versão JSON. A DECISÃO é a mesmíssima do console —
|
|
257
|
+
* `isSudoSatisfied`, de onde o `requireSudo` do form também tira a dele
|
|
258
|
+
* (mesma setting, mesma janela de graça, mesma vinculação à conta, mesmo
|
|
259
|
+
* fail-safe) —, só a RECUSA muda de forma: `403 sudo_required` em vez do
|
|
260
|
+
* redirect para `/account/confirm`.
|
|
261
|
+
*
|
|
262
|
+
* Chama a decisão, e não o `requireSudo`, porque aquele ESCREVE na resposta
|
|
263
|
+
* ao recusar (`response.redirect`): sobrepor um 403 depois deixaria um
|
|
264
|
+
* `Location` pendurado numa resposta que não é redirect. Devolve `false`
|
|
265
|
+
* quando já respondeu.
|
|
266
|
+
*/
|
|
267
|
+
async #requireSudoJson(ctx) {
|
|
268
|
+
const settings = await resolveRuntimeSettings(ctx);
|
|
269
|
+
if (await isSudoSatisfied(ctx, settings))
|
|
270
|
+
return true;
|
|
271
|
+
ctx.response.status(403).send(apiErr('sudo_required', 'Identity confirmation required.'));
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
/** Notificação de segurança best-effort (nunca derruba a operação). */
|
|
275
|
+
async #notice(ctx, c, kind) {
|
|
276
|
+
const account = await c.cfg.accountStore.findById(c.userId);
|
|
277
|
+
if (!account)
|
|
278
|
+
return;
|
|
279
|
+
await dispatchSecurityNotice(ctx, {
|
|
280
|
+
account: { id: c.userId, email: account.email },
|
|
281
|
+
kind: kind,
|
|
282
|
+
ip: ctx.request.ip?.() ?? null,
|
|
283
|
+
timestamp: new Date().toISOString(),
|
|
284
|
+
}, c.cfg.mail, c.cfg.audit, c.cfg);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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 '../augmentations.js';
|
|
49
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
50
|
+
export default class AccountOrgsApiController {
|
|
51
|
+
#private;
|
|
52
|
+
/** Criar uma org. Exige `allowSelfCreate` na política efetiva. */
|
|
53
|
+
createOrg(ctx: HttpContext): Promise<void | {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
slug: string;
|
|
57
|
+
logoUrl: string | null;
|
|
58
|
+
role: string;
|
|
59
|
+
}>;
|
|
60
|
+
/** Define a org ativa (cookie `authkit_active_org`). Valida membership. */
|
|
61
|
+
activateOrg(ctx: HttpContext): Promise<void | {
|
|
62
|
+
ok: boolean;
|
|
63
|
+
activeOrgId: string;
|
|
64
|
+
slug: any;
|
|
65
|
+
role: any;
|
|
66
|
+
}>;
|
|
67
|
+
/** Limpa a org ativa. Não depende de membership (só apaga o cookie). */
|
|
68
|
+
deactivateOrg(ctx: HttpContext): Promise<{
|
|
69
|
+
ok: boolean;
|
|
70
|
+
activeOrgId: null;
|
|
71
|
+
}>;
|
|
72
|
+
/** Sai da org. O store recusa o último owner (`reason: 'last_owner'`). */
|
|
73
|
+
leaveOrg(ctx: HttpContext): Promise<void | {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
orgId: string;
|
|
76
|
+
}>;
|
|
77
|
+
/** Convida alguém por e-mail. Exige owner/admin; papel validado no catálogo. */
|
|
78
|
+
inviteMember(ctx: HttpContext): Promise<void | {
|
|
79
|
+
id: any;
|
|
80
|
+
organizationId: string;
|
|
81
|
+
email: any;
|
|
82
|
+
role: string;
|
|
83
|
+
expiresAt: any;
|
|
84
|
+
createdAt: any;
|
|
85
|
+
}>;
|
|
86
|
+
/** Revoga um convite pendente. Escopado por org (anti-IDOR), como o form. */
|
|
87
|
+
revokeInvitation(ctx: HttpContext): Promise<void | {
|
|
88
|
+
ok: boolean;
|
|
89
|
+
revoked: string;
|
|
90
|
+
}>;
|
|
91
|
+
/** Remove um membro. Exige owner/admin na org. */
|
|
92
|
+
removeMember(ctx: HttpContext): Promise<void | {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
orgId: string;
|
|
95
|
+
accountId: string;
|
|
96
|
+
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Troca o papel de um membro.
|
|
99
|
+
*
|
|
100
|
+
* Não existe equivalente member-facing em formulário (o console HTML só
|
|
101
|
+
* troca papel pelo caminho ADMIN). As regras vieram, então, da interseção
|
|
102
|
+
* das duas superfícies que já existem: o guard de owner/admin + catálogo de
|
|
103
|
+
* papéis do `invite` member-facing, e a checagem de `last_owner` do
|
|
104
|
+
* `AdminOrgsService.updateMemberRole`. Conceder `owner` continua privativo
|
|
105
|
+
* de um owner.
|
|
106
|
+
*/
|
|
107
|
+
updateMemberRole(ctx: HttpContext): Promise<void | {
|
|
108
|
+
ok: boolean;
|
|
109
|
+
orgId: string;
|
|
110
|
+
accountId: string;
|
|
111
|
+
role: string;
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* Aceita um convite pelo token do e-mail.
|
|
115
|
+
*
|
|
116
|
+
* Diferente do form (montado FORA do guard para tratar o não-autenticado com
|
|
117
|
+
* um redirect para o login), esta rota vive DENTRO do `accountGuard`: uma
|
|
118
|
+
* tela SPA já está logada, e a resposta a um visitante anônimo aqui teria de
|
|
119
|
+
* ser 401 em JSON, não uma navegação.
|
|
120
|
+
*/
|
|
121
|
+
acceptInvitation(ctx: HttpContext): Promise<void | {
|
|
122
|
+
ok: boolean;
|
|
123
|
+
organizationId: any;
|
|
124
|
+
role: any;
|
|
125
|
+
}>;
|
|
126
|
+
}
|