@adonis-agora/authkit-server 0.58.3 → 0.60.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/index.d.ts CHANGED
@@ -23,8 +23,8 @@ export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account
23
23
  export { lucidStores } from './src/accounts/lucid_stores.js';
24
24
  export type { LucidStoresModels, LucidStoresOptions, LucidStoresResult, } from './src/accounts/lucid_stores.js';
25
25
  export type { LucidAccountStoreOptions, AccountSecretEncrypter, } from './src/accounts/lucid_account_store.js';
26
- export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, OtpLoginCapability, OtpLoginVerifyResult, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
27
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
26
+ export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, ProviderIdentitySummary, AccountSecurityCapability, AccountStatusCapability, ProfileCapability, MagicLinkCapability, OtpLoginCapability, OtpLoginVerifyResult, EmailVerificationStatusCapability, AccountDeletionCapability, AccountImportCapability, ImportAccountInput, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, LoginMethodsPreferenceCapability, } from './src/accounts/account_store.js';
27
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, supportsLoginMethodsPreference, } from './src/accounts/account_store.js';
28
28
  export { type OtpLoginConfigInput, type ResolvedOtpLoginConfig, type OtpVerifyOutcome, resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
29
29
  export { PasswordManager, PasswordPolicyError, } from './src/password/password_manager.js';
30
30
  export type { PasswordConfigInput, LegacyPasswordVerifier, PasswordVerifyResult, } from './src/password/password_manager.js';
@@ -125,6 +125,8 @@ export { resolveEffectiveBotProtection } from './src/host/bot_protection.js';
125
125
  export type { BotProtectionSetting } from './src/host/bot_protection.js';
126
126
  export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVerifiedEmail, resolveEffectiveMaintenanceMode, resolveEffectiveAuthMethods, configLockedAuthMethods, } from './src/host/runtime_toggles.js';
127
127
  export type { SettingKey, RegistrationSetting, RequireVerifiedEmailSetting, MaintenanceModeSetting, ResolvedMaintenanceMode, AuthMethodsSetting, ResolvedAuthMethods, AuthMethodsCapabilities, AuthMethodsConfigOverride, } from './src/host/runtime_toggles.js';
128
+ export { USER_LOGIN_METHOD_KEYS, normalizeUserLoginMethods, parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, } from './src/host/user_login_methods.js';
129
+ export type { UserLoginMethodKey, UserLoginMethods, ResolvedUserLoginMethods, } from './src/host/user_login_methods.js';
128
130
  export { resolveRegistration } from './src/define_config.js';
129
131
  export type { RegistrationConfigInput, ResolvedRegistrationConfig, } from './src/define_config.js';
130
132
  export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
package/build/index.js CHANGED
@@ -14,7 +14,7 @@ export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload,
14
14
  export { resolveBotProtection, botProtectionApplies, extractBotToken, verifyBotProtection, guardBotProtection, DEFAULT_BOT_TOKEN_FIELDS, } from './src/host/bot_protection.js';
15
15
  export { lucidAccountStore, appKeyEncrypter } from './src/accounts/lucid_account_store.js';
16
16
  export { lucidStores } from './src/accounts/lucid_stores.js';
17
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, } from './src/accounts/account_store.js';
17
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsAccountStatus, supportsProfile, supportsMagicLink, supportsOtpLogin, supportsEmailVerificationStatus, supportsAccountDeletion, supportsAccountImport, supportsLoginMethodsPreference, } from './src/accounts/account_store.js';
18
18
  // Login por OTP (código digitável): config + helpers puros.
19
19
  export { resolveOtpLoginConfig, generateOtpCode, evaluateLoginOtp, OTP_LOGIN_DEFAULTS, } from './src/host/otp_login.js';
20
20
  // Gerência de senha: lazy rehash + legacy verifier, política e checagem de vazamento.
@@ -85,6 +85,8 @@ export { buildKeysStatus, rotateNow } from './src/host/key_rotation_actions.js';
85
85
  export { resolveEffectiveBotProtection } from './src/host/bot_protection.js';
86
86
  // Runtime toggles (registration, require_verified_email, maintenance_mode).
87
87
  export { SETTING_KEYS, resolveEffectiveRegistration, resolveEffectiveRequireVerifiedEmail, resolveEffectiveMaintenanceMode, resolveEffectiveAuthMethods, configLockedAuthMethods, } from './src/host/runtime_toggles.js';
88
+ // Tipos de login POR USUÁRIO (self-service no console de conta).
89
+ export { USER_LOGIN_METHOD_KEYS, normalizeUserLoginMethods, parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, } from './src/host/user_login_methods.js';
88
90
  export { resolveRegistration } from './src/define_config.js';
89
91
  export { getAccountId, realAccountId, hasAccountSession, consoleLoginUrl, } from './src/host/console_session.js';
90
92
  export { ACCOUNT_SESSION_KEY } from './src/host/account_session_key.js';
@@ -1,4 +1,5 @@
1
1
  /** DTO público da conta — o que o provider e os controllers enxergam. Nunca um model Lucid. */
2
+ import type { UserLoginMethods } from '../host/user_login_methods.js';
2
3
  export interface AuthAccount {
3
4
  id: string;
4
5
  email: string;
@@ -211,6 +212,22 @@ export interface ProviderIdentitySummary {
211
212
  providerUserId: string;
212
213
  email?: string | null;
213
214
  }
215
+ /**
216
+ * Preferência POR USUÁRIO de tipos de login (self-service no console de conta).
217
+ * CAPACIDADE opcional, montada quando o model tem a coluna `login_methods`
218
+ * (JSONB) — hosts adotam por migração própria. `null` = sem preferência (herda
219
+ * os métodos globais efetivos). Ver `host/user_login_methods.ts` para o shape
220
+ * canônico (`UserLoginMethods`) e a semântica da interseção.
221
+ */
222
+ export interface LoginMethodsPreferenceCapability {
223
+ /** Lê a preferência da conta; null quando não há preferência persistida. */
224
+ getLoginMethods(accountId: string): Promise<UserLoginMethods | null>;
225
+ /**
226
+ * Grava a preferência da conta. Objeto vazio ou null REMOVE a preferência
227
+ * (volta a herdar os globais). No-op se a conta não existe.
228
+ */
229
+ setLoginMethods(accountId: string, methods: UserLoginMethods | null): Promise<void>;
230
+ }
214
231
  /**
215
232
  * MFA / TOTP. Stores sem suporte a MFA omitem a capacidade inteira; o interaction
216
233
  * flow trata a ausência como "MFA desligado".
@@ -599,7 +616,7 @@ export type AccountStore = CoreAccountStore & {
599
616
  * blocos `Partial<...>` de capacidades probáveis.
600
617
  */
601
618
  readonly connectionName?: string;
602
- } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability>;
619
+ } & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability & OtpLoginCapability & EmailVerificationStatusCapability & AccountDeletionCapability & AccountImportCapability & OrganizationsCapability & PasswordHistoryCapability & PasswordExpirationCapability & LoginMethodsPreferenceCapability>;
603
620
  /** Type guard: o store implementa a capacidade de MFA / TOTP. */
604
621
  export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
605
622
  /**
@@ -634,3 +651,5 @@ export declare function supportsAccountImport(store: AccountStore): store is Acc
634
651
  export declare function supportsPasswordHistory(store: AccountStore): store is AccountStore & PasswordHistoryCapability;
635
652
  /** Type guard: o store implementa expiração de senha (password_changed_at coluna). */
636
653
  export declare function supportsPasswordExpiration(store: AccountStore): store is AccountStore & PasswordExpirationCapability;
654
+ /** Type guard: o store implementa a preferência por usuário de tipos de login. */
655
+ export declare function supportsLoginMethodsPreference(store: AccountStore): store is AccountStore & LoginMethodsPreferenceCapability;
@@ -63,3 +63,7 @@ export function supportsPasswordHistory(store) {
63
63
  export function supportsPasswordExpiration(store) {
64
64
  return typeof store.getPasswordChangedAt === 'function';
65
65
  }
66
+ /** Type guard: o store implementa a preferência por usuário de tipos de login. */
67
+ export function supportsLoginMethodsPreference(store) {
68
+ return typeof store.getLoginMethods === 'function';
69
+ }
@@ -1,6 +1,7 @@
1
1
  import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
2
2
  import { PasswordManager } from '../password/password_manager.js';
3
3
  import { buildCore } from './lucid_store/core.js';
4
+ import { buildLoginMethods, supportsLoginMethodsColumn } from './lucid_store/login_methods.js';
4
5
  import { buildMfa } from './lucid_store/mfa.js';
5
6
  import { buildOrganizations } from './lucid_store/organizations.js';
6
7
  import { buildPasswordExpiration, buildPasswordHistory } from './lucid_store/password_hygiene.js';
@@ -165,7 +166,11 @@ export function lucidAccountStore(Model, options = {}) {
165
166
  return encrypter.decrypt(stored);
166
167
  },
167
168
  toAccount: (row) => ({
168
- id: row.id,
169
+ // `String(...)`: o `id` do AuthAccount vira o claim `sub`, que a spec OIDC
170
+ // exige que seja string. Tabelas adotadas em brownfield quase sempre têm
171
+ // `id` INTEGER auto-increment, e sem a coerção o número vazava para o
172
+ // token (e para todo call-site tipado como string).
173
+ id: String(row.id),
169
174
  email: row.email,
170
175
  globalRoles: row.globalRoles ?? [],
171
176
  name: row.fullName ?? undefined,
@@ -208,6 +213,9 @@ export function lucidAccountStore(Model, options = {}) {
208
213
  ...buildDeletion(ctx),
209
214
  // Expiração de senha: só quando o model tem a coluna `password_changed_at`.
210
215
  ...(hasColumn(Model, 'passwordChangedAt') ? buildPasswordExpiration(ctx) : {}),
216
+ // Preferência por usuário de tipos de login: só quando o model tem a coluna
217
+ // `login_methods` (JSONB). Sem a coluna → capacidade ausente (feature no-op).
218
+ ...(supportsLoginMethodsColumn(Model) ? buildLoginMethods(ctx) : {}),
211
219
  // Organizations (multi-tenancy): só quando os três models foram fornecidos.
212
220
  ...(OrgModels
213
221
  ? buildOrganizations({
@@ -251,6 +259,16 @@ export function lucidAccountStore(Model, options = {}) {
251
259
  */
252
260
  __mfaIssuer: mfaIssuer,
253
261
  __webauthn: options.webauthn,
262
+ /**
263
+ * O model Lucid por trás deste store — exposto para o `authkit:doctor`
264
+ * inspecionar `$columnsDefinitions` e avisar sobre colunas ausentes ANTES
265
+ * que o fluxo correspondente quebre em produção (ver
266
+ * `doctor/checks.ts#checkAccountStoreColumns`). Importa sobretudo em
267
+ * adoção brownfield, onde o model aponta para uma tabela `users` que já
268
+ * existia e não passou pelas migrations do authkit.
269
+ * NÃO faz parte do contrato AccountStore.
270
+ */
271
+ __model: Model,
254
272
  };
255
273
  // Histórico de senhas: capability-probed via tabela `auth_password_history`.
256
274
  // A versão síncrona não pode fazer o probe de DB, então a capability fica
@@ -287,7 +305,8 @@ export async function lucidAccountStoreAsync(Model, options = {}) {
287
305
  sealSecret: (s) => s,
288
306
  openSecret: (s) => s ?? null,
289
307
  toAccount: (row) => ({
290
- id: row.id,
308
+ // Ver a nota em `toAccount` acima: `sub` precisa ser string.
309
+ id: String(row.id),
291
310
  email: row.email,
292
311
  globalRoles: row.globalRoles ?? [],
293
312
  name: row.fullName ?? undefined,
@@ -0,0 +1,11 @@
1
+ import type { LoginMethodsPreferenceCapability } from '../account_store.js';
2
+ import type { LucidStoreContext } from './shared.js';
3
+ /**
4
+ * Preferência por usuário de tipos de login sobre a coluna `login_methods`
5
+ * (JSONB) do model. Só deve ser montada quando a coluna existe
6
+ * ({@link hasColumn}) — caso contrário a capacidade fica ausente e a feature
7
+ * degrada para no-op (hosts adotam por migração própria).
8
+ */
9
+ export declare function buildLoginMethods(ctx: LucidStoreContext): LoginMethodsPreferenceCapability;
10
+ /** Indica se o model suporta a preferência de tipos de login (coluna presente). */
11
+ export declare function supportsLoginMethodsColumn(Model: any): boolean;
@@ -0,0 +1,32 @@
1
+ import { normalizeUserLoginMethods } from '../../host/user_login_methods.js';
2
+ import { hasColumn } from './status_profile.js';
3
+ /**
4
+ * Preferência por usuário de tipos de login sobre a coluna `login_methods`
5
+ * (JSONB) do model. Só deve ser montada quando a coluna existe
6
+ * ({@link hasColumn}) — caso contrário a capacidade fica ausente e a feature
7
+ * degrada para no-op (hosts adotam por migração própria).
8
+ */
9
+ export function buildLoginMethods(ctx) {
10
+ const { Model } = ctx;
11
+ return {
12
+ async getLoginMethods(accountId) {
13
+ const row = await Model.find(accountId);
14
+ if (!row)
15
+ return null;
16
+ return normalizeUserLoginMethods(row.loginMethods);
17
+ },
18
+ async setLoginMethods(accountId, methods) {
19
+ const row = await Model.find(accountId);
20
+ if (!row)
21
+ return;
22
+ // Objeto vazio/null → grava null (sem preferência = herda globais).
23
+ const value = methods && Object.keys(methods).length > 0 ? normalizeUserLoginMethods(methods) : null;
24
+ row.loginMethods = value;
25
+ await row.save();
26
+ },
27
+ };
28
+ }
29
+ /** Indica se o model suporta a preferência de tipos de login (coluna presente). */
30
+ export function supportsLoginMethodsColumn(Model) {
31
+ return hasColumn(Model, 'loginMethods');
32
+ }
@@ -21,6 +21,16 @@ export interface AuditEvent {
21
21
  clientId?: string | null;
22
22
  /** Impersonation: quem agiu (o admin). */
23
23
  actorId?: string | null;
24
+ /**
25
+ * Organização (tenant) a que o evento pertence, quando houver. Campo de
26
+ * PRIMEIRA CLASSE — e não uma chave de `metadata` — de propósito: `metadata`
27
+ * é livre e por isso é DROPADO na projeção que vai para o barramento de
28
+ * diagnostics (ver `redactAuditEventForDiagnostics`), enquanto os ids internos
29
+ * opacos (`accountId`/`actorId`/`clientId`/`orgId`) são preservados. É esse
30
+ * campo que permite a um consumidor do barramento — p.ex. o provisioning do
31
+ * `@adonis-agora/authz` — saber QUAL tenant provisionar sem receber PII.
32
+ */
33
+ orgId?: string | null;
24
34
  ip?: string | null;
25
35
  metadata?: Record<string, unknown>;
26
36
  }
@@ -57,6 +57,19 @@ export declare function checkClients(input: DoctorInput): Finding;
57
57
  export declare function checkAdapterVolatility(input: DoctorInput): Finding | null;
58
58
  /** accountStore presente + quais capacidades implementa. */
59
59
  export declare function checkAccountStore(input: DoctorInput): Finding[];
60
+ /**
61
+ * Valida que o model por trás do account store tem as colunas que o store
62
+ * escreve. Existe por causa da adoção brownfield: quando o host aponta o
63
+ * AuthKit para a tabela `users` que ele JÁ tinha, as colunas dos mixins
64
+ * (`password_reset_token`, `email_verification_token`, …) não existem, o app
65
+ * sobe normalmente, e a falha só aparece quando alguém clica em "esqueci minha
66
+ * senha" — em produção. Este check antecipa isso para o boot.
67
+ *
68
+ * Silencioso quando não há o que inspecionar: um store custom (não-Lucid) não
69
+ * expõe `__model`, e nesse caso o contrato é responsabilidade de quem o
70
+ * implementou.
71
+ */
72
+ export declare function checkAccountStoreColumns(input: DoctorInput): Finding[];
60
73
  /** session provider configurado + warn se cookie store com tokenSets grandes. */
61
74
  export declare function checkSession(input: DoctorInput): Finding[];
62
75
  /** Hint de exceções de CSRF do shield para o mountPath. */
@@ -134,6 +134,84 @@ export function checkAccountStore(input) {
134
134
  });
135
135
  return findings;
136
136
  }
137
+ /**
138
+ * Propriedades do model que o account store Lucid escreve/lê, agrupadas pelo
139
+ * FLUXO que deixa de funcionar quando a coluna não existe. Agrupar por fluxo (e
140
+ * não listar colunas soltas) é o que torna o achado acionável: "falta
141
+ * `password_reset_token`" não diz nada a quem não conhece o interior da lib;
142
+ * "o reset de senha vai quebrar" diz.
143
+ */
144
+ const REQUIRED_COLUMN_GROUPS = [
145
+ { flow: 'identity (every flow)', properties: ['email'] },
146
+ { flow: 'password login', properties: ['password'] },
147
+ { flow: 'role claims / admin', properties: ['globalRoles'] },
148
+ {
149
+ flow: 'password reset',
150
+ properties: ['passwordResetToken', 'passwordResetExpiresAt'],
151
+ },
152
+ {
153
+ flow: 'email verification',
154
+ properties: ['emailVerifiedAt', 'emailVerificationToken'],
155
+ },
156
+ ];
157
+ /**
158
+ * Propriedades OPCIONAIS: a capability correspondente é detectada pela presença
159
+ * da coluna, então ausência é configuração válida (feature desligada) e nunca
160
+ * um erro — só vale reportar para que a ausência seja uma escolha, e não uma
161
+ * surpresa.
162
+ */
163
+ const OPTIONAL_COLUMN_GROUPS = [
164
+ { capability: 'profile (name/avatar)', properties: ['fullName', 'avatarUrl'] },
165
+ { capability: 'disable/enable account', properties: ['disabledAt'] },
166
+ { capability: 'password expiration', properties: ['passwordChangedAt'] },
167
+ ];
168
+ /**
169
+ * Valida que o model por trás do account store tem as colunas que o store
170
+ * escreve. Existe por causa da adoção brownfield: quando o host aponta o
171
+ * AuthKit para a tabela `users` que ele JÁ tinha, as colunas dos mixins
172
+ * (`password_reset_token`, `email_verification_token`, …) não existem, o app
173
+ * sobe normalmente, e a falha só aparece quando alguém clica em "esqueci minha
174
+ * senha" — em produção. Este check antecipa isso para o boot.
175
+ *
176
+ * Silencioso quando não há o que inspecionar: um store custom (não-Lucid) não
177
+ * expõe `__model`, e nesse caso o contrato é responsabilidade de quem o
178
+ * implementou.
179
+ */
180
+ export function checkAccountStoreColumns(input) {
181
+ const store = input.authkitConfig?.accountStore;
182
+ const model = store?.__model;
183
+ const definitions = model?.$columnsDefinitions;
184
+ if (!definitions || typeof definitions.get !== 'function')
185
+ return [];
186
+ /** Nome da coluna real (o que o DBA procura), com fallback à propriedade. */
187
+ const columnOf = (property) => definitions.get(property)?.columnName ?? property;
188
+ const missing = (properties) => properties.filter((p) => !definitions.has(p));
189
+ const findings = [];
190
+ for (const { flow, properties } of REQUIRED_COLUMN_GROUPS) {
191
+ const absent = missing(properties);
192
+ if (absent.length === 0)
193
+ continue;
194
+ const named = absent.map((p) => `\`${p}\` (column \`${columnOf(p)}\`)`).join(', ');
195
+ findings.push({
196
+ level: 'error',
197
+ message: `accountStore model (${model.name ?? 'model'}) is missing ${named} — ${flow} will fail at runtime. Add the column(s) with a migration, or map an existing one with @column({ columnName: '…' }).`,
198
+ });
199
+ }
200
+ const off = OPTIONAL_COLUMN_GROUPS.filter((g) => missing(g.properties).length > 0);
201
+ if (off.length > 0) {
202
+ findings.push({
203
+ level: 'ok',
204
+ message: `Capabilities off (column absent): ${off.map((g) => g.capability).join(', ')}.`,
205
+ });
206
+ }
207
+ if (findings.every((f) => f.level === 'ok')) {
208
+ findings.unshift({
209
+ level: 'ok',
210
+ message: `accountStore model (${model.name ?? 'model'}) has every required column.`,
211
+ });
212
+ }
213
+ return findings;
214
+ }
137
215
  /** session provider configurado + warn se cookie store com tokenSets grandes. */
138
216
  export function checkSession(input) {
139
217
  if (!input.peers.session) {
@@ -845,6 +923,7 @@ export function runAllChecks(input) {
845
923
  if (volatility)
846
924
  findings.push(volatility);
847
925
  findings.push(...checkAccountStore(input));
926
+ findings.push(...checkAccountStoreColumns(input));
848
927
  findings.push(...checkSession(input));
849
928
  findings.push(checkShield(input));
850
929
  findings.push(checkAlly(input));
@@ -56,9 +56,13 @@ export declare function signWebhookBody(body: string, secret: string): string;
56
56
  * e-mail). Nenhum data provider do dashboard lê `metadata`, então dropá-lo é
57
57
  * seguro;
58
58
  * - MANTÉM `type` (a família do evento — o que os providers agregam) e os ids
59
- * internos opacos `accountId`/`actorId`/`clientId` (correlação de subject/actor
60
- * no dashboard; NÃO são PII direta e, sem `email`/`ip`/`metadata` e com a linha
61
- * da conta já deletada, não são reidentificáveis).
59
+ * internos opacos `accountId`/`actorId`/`clientId`/`orgId` (correlação de
60
+ * subject/actor/tenant no dashboard; NÃO são PII direta e, sem `email`/`ip`/
61
+ * `metadata` e com a linha da conta já deletada, não são reidentificáveis).
62
+ * O `orgId` está aqui porque é o que torna o barramento UTILIZÁVEL para
63
+ * provisioning multi-tenant (o `@adonis-agora/authz` escuta
64
+ * `agora:authkit:organization.*` e precisa saber qual tenant escopar) —
65
+ * sem ele o consumidor recebia o tipo do evento e mais nada.
62
66
  *
63
67
  * Assim o Telescope nunca armazena PII bruta e a deleção de conta não precisa de uma
64
68
  * etapa de purge cross-lib. Os ramos `onEvent`/`webhook` (integrações que o host
@@ -20,6 +20,7 @@ export function buildWebhookBody(event) {
20
20
  accountId: event.accountId ?? null,
21
21
  email: event.email ?? null,
22
22
  clientId: event.clientId ?? null,
23
+ orgId: event.orgId ?? null,
23
24
  ip: event.ip ?? null,
24
25
  metadata: event.metadata ?? {},
25
26
  ts: new Date().toISOString(),
@@ -50,9 +51,13 @@ export function signWebhookBody(body, secret) {
50
51
  * e-mail). Nenhum data provider do dashboard lê `metadata`, então dropá-lo é
51
52
  * seguro;
52
53
  * - MANTÉM `type` (a família do evento — o que os providers agregam) e os ids
53
- * internos opacos `accountId`/`actorId`/`clientId` (correlação de subject/actor
54
- * no dashboard; NÃO são PII direta e, sem `email`/`ip`/`metadata` e com a linha
55
- * da conta já deletada, não são reidentificáveis).
54
+ * internos opacos `accountId`/`actorId`/`clientId`/`orgId` (correlação de
55
+ * subject/actor/tenant no dashboard; NÃO são PII direta e, sem `email`/`ip`/
56
+ * `metadata` e com a linha da conta já deletada, não são reidentificáveis).
57
+ * O `orgId` está aqui porque é o que torna o barramento UTILIZÁVEL para
58
+ * provisioning multi-tenant (o `@adonis-agora/authz` escuta
59
+ * `agora:authkit:organization.*` e precisa saber qual tenant escopar) —
60
+ * sem ele o consumidor recebia o tipo do evento e mais nada.
56
61
  *
57
62
  * Assim o Telescope nunca armazena PII bruta e a deleção de conta não precisa de uma
58
63
  * etapa de purge cross-lib. Os ramos `onEvent`/`webhook` (integrações que o host
@@ -65,6 +70,7 @@ export function redactAuditEventForDiagnostics(event) {
65
70
  accountId: event.accountId ?? null,
66
71
  actorId: event.actorId ?? null,
67
72
  clientId: event.clientId ?? null,
73
+ orgId: event.orgId ?? null,
68
74
  };
69
75
  }
70
76
  /**
@@ -30,6 +30,7 @@
30
30
  import '../augmentations.js';
31
31
  import type { HttpContext } from '@adonisjs/core/http';
32
32
  export default class AccountApiController {
33
+ #private;
33
34
  /** Perfil + flags do usuário logado. */
34
35
  me(ctx: HttpContext): Promise<void | {
35
36
  id: any;
@@ -171,6 +172,43 @@ export default class AccountApiController {
171
172
  available: any;
172
173
  };
173
174
  }>;
175
+ /**
176
+ * Preferência POR USUÁRIO de tipos de login + o catálogo do que está
177
+ * disponível/travado. A interseção com os métodos globais efetivos é feita
178
+ * AQUI (a UI mostra o estado final; o enforcement no login reusa o resolver).
179
+ */
180
+ getLoginMethods(ctx: HttpContext): Promise<{
181
+ supported: false;
182
+ methods: null;
183
+ available: null;
184
+ locked: null;
185
+ } | {
186
+ supported: true;
187
+ methods: any;
188
+ available: {
189
+ password: boolean;
190
+ magicLink: boolean;
191
+ passkey: boolean;
192
+ social: string[];
193
+ forgotPassword: boolean;
194
+ };
195
+ locked: {
196
+ password: boolean;
197
+ magicLink: boolean;
198
+ passkey: boolean;
199
+ social: boolean;
200
+ };
201
+ }>;
202
+ /** Grava a preferência de tipos de login do usuário logado. */
203
+ updateLoginMethods(ctx: HttpContext): Promise<void | {
204
+ ok: boolean;
205
+ methods: {
206
+ password?: boolean;
207
+ magicLink?: boolean;
208
+ passkey?: boolean;
209
+ social?: boolean;
210
+ };
211
+ }>;
174
212
  /** Lista passkeys do usuário logado. */
175
213
  listPasskeys(ctx: HttpContext): Promise<{
176
214
  supported: boolean;
@@ -28,7 +28,7 @@
28
28
  * GET /account/api/orgs/invitations → convites pendentes
29
29
  */
30
30
  import '../augmentations.js';
31
- import { supportsAccountSecurity, supportsOrganizations, supportsPasskeys, supportsProfile, } from '../../accounts/account_store.js';
31
+ import { supportsAccountSecurity, supportsLoginMethodsPreference, supportsMagicLink, supportsOrganizations, supportsPasskeys, supportsProfile, } 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';
@@ -39,11 +39,12 @@ import { AvatarUploadError, isAvatarUploadSupported, storeAvatar } from '../avat
39
39
  import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail } from '../default_mailer.js';
40
40
  import { translate } from '../i18n.js';
41
41
  import { authkitOrigin } from '../origin.js';
42
- import { resolveRuntimeSettings } from '../runtime_settings.js';
43
- import { resolveEffectiveEmailChange, resolveEffectivePasswordHistory, } from '../runtime_toggles.js';
42
+ import { resolveRuntimeSettings, resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
43
+ import { configLockedAuthMethods, resolveEffectiveAuthMethods, resolveEffectiveEmailChange, resolveEffectivePasswordHistory, } from '../runtime_toggles.js';
44
44
  import { dispatchSecurityNotice } from '../security_notice_service.js';
45
45
  import { enrichSessionsWithContext } from '../session_context.js';
46
46
  import { SUDO_MODE_DEFAULTS, isSudoActive, requireSudo, resolveEffectiveSudoMode, } from '../sudo_mode.js';
47
+ import { parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
47
48
  import { changeEmailValidator, changePasswordValidator, updateProfileValidator, } from '../validators.js';
48
49
  // ---------------------------------------------------------------------------
49
50
  // Helpers (shared with existing controllers — kept local to avoid coupling)
@@ -641,6 +642,86 @@ export default class AccountApiController {
641
642
  recovery: { available: enabled },
642
643
  };
643
644
  }
645
+ // ─── GET /account/api/login-methods ─────────────────────────────────────
646
+ /**
647
+ * Preferência POR USUÁRIO de tipos de login + o catálogo do que está
648
+ * disponível/travado. A interseção com os métodos globais efetivos é feita
649
+ * AQUI (a UI mostra o estado final; o enforcement no login reusa o resolver).
650
+ */
651
+ async getLoginMethods(ctx) {
652
+ const service = await ctx.containerResolver.make('authkit.server');
653
+ const cfg = service.config;
654
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
655
+ const supported = supportsLoginMethodsPreference(cfg.accountStore);
656
+ if (!supported) {
657
+ return { supported: false, methods: null, available: null, locked: null };
658
+ }
659
+ const pref = await cfg.accountStore.getLoginMethods(userId);
660
+ const { resolved: global, locked: cfgLocked } = await this.#resolveGlobalAuthMethods(ctx, cfg);
661
+ const effective = resolveEffectiveUserLoginMethods(global, pref);
662
+ return {
663
+ supported: true,
664
+ // Preferência crua do usuário (o que ELE escolheu; null = sem preferência).
665
+ methods: pref ?? {},
666
+ // Estado final por método (global ∩ preferência) — o que a tela de login mostra.
667
+ available: {
668
+ password: effective.password,
669
+ magicLink: effective.magicLink,
670
+ passkey: effective.passkey,
671
+ social: effective.social,
672
+ forgotPassword: effective.forgotPassword,
673
+ },
674
+ // Métodos que o usuário NÃO pode controlar (globalmente off ou pin de config).
675
+ // Pin de config: `cfgLocked` lista as chaves fixadas no arquivo de config.
676
+ locked: {
677
+ password: !global.password || cfgLocked.includes('password'),
678
+ magicLink: !global.magicLink || cfgLocked.includes('magicLink'),
679
+ passkey: !global.passkey || cfgLocked.includes('passkey'),
680
+ social: global.social.length === 0,
681
+ },
682
+ };
683
+ }
684
+ // ─── PUT /account/api/login-methods ─────────────────────────────────────
685
+ /** Grava a preferência de tipos de login do usuário logado. */
686
+ async updateLoginMethods(ctx) {
687
+ const service = await ctx.containerResolver.make('authkit.server');
688
+ const cfg = service.config;
689
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
690
+ if (!supportsLoginMethodsPreference(cfg.accountStore)) {
691
+ return ctx.response.notFound(apiErr('not_supported', 'Login methods preference not supported.'));
692
+ }
693
+ const parsed = parseUserLoginMethodsPayload(ctx.request.body());
694
+ if (!parsed.ok) {
695
+ return ctx.response.badRequest(apiErr(parsed.error, 'Invalid login methods payload.'));
696
+ }
697
+ // All-off guard server-side: a preferência não pode zerar todos os métodos
698
+ // GLOBAIS disponíveis. Um `false` que deixaria zero métodos é recusado.
699
+ const { resolved: global } = await this.#resolveGlobalAuthMethods(ctx, cfg);
700
+ const value = { ...parsed.value };
701
+ const wouldBeAllOff = (value.password === false || !global.password) &&
702
+ (value.magicLink === false || !global.magicLink) &&
703
+ (value.passkey === false || !global.passkey) &&
704
+ (value.social === false || global.social.length === 0);
705
+ if (wouldBeAllOff) {
706
+ return ctx.response.badRequest(apiErr('all_methods_off', 'Cannot disable every login method.'));
707
+ }
708
+ await cfg.accountStore.setLoginMethods(userId, Object.keys(value).length > 0 ? value : null);
709
+ return { ok: true, methods: value };
710
+ }
711
+ /**
712
+ * Métodos globais efetivos + pins de config, compartilhados pelos handlers
713
+ * acima. Fail-safe: erro → defaults conservadores.
714
+ */
715
+ async #resolveGlobalAuthMethods(ctx, cfg) {
716
+ const settings = await resolveRuntimeSettingsOrNoop(ctx);
717
+ const resolved = await resolveEffectiveAuthMethods(settings, {
718
+ configuredSocialProviders: cfg.social?.providers ?? [],
719
+ magicLinkCapable: cfg.passwordless?.magicLink && supportsMagicLink(cfg.accountStore),
720
+ passkeyCapable: supportsPasskeys(cfg.accountStore),
721
+ configOverrides: cfg.authMethods,
722
+ });
723
+ return { resolved, locked: configLockedAuthMethods(cfg.authMethods) };
724
+ }
644
725
  // ─── GET /account/api/passkeys ──────────────────────────────────────────
645
726
  /** Lista passkeys do usuário logado. */
646
727
  async listPasskeys(ctx) {
@@ -110,6 +110,7 @@ export class AdminOrgsService {
110
110
  accountId: input.ownerAccountId,
111
111
  actorId: actor.actorId,
112
112
  ip: actor.ip,
113
+ orgId: org.id,
113
114
  metadata: { slug: org.slug, ...(actor.source ? { actor: actor.source } : {}) },
114
115
  });
115
116
  return org;
@@ -137,6 +138,7 @@ export class AdminOrgsService {
137
138
  type: 'organization.updated',
138
139
  actorId: actor.actorId,
139
140
  ip: actor.ip,
141
+ orgId,
140
142
  metadata: { orgId, ...(actor.source ? { actor: actor.source } : {}) },
141
143
  });
142
144
  return updated;
@@ -158,6 +160,7 @@ export class AdminOrgsService {
158
160
  type: 'organization.deleted',
159
161
  actorId: actor.actorId,
160
162
  ip: actor.ip,
163
+ orgId,
161
164
  metadata: { orgId, slug: existing.slug, ...(actor.source ? { actor: actor.source } : {}) },
162
165
  });
163
166
  return { ok: true };
@@ -182,6 +185,7 @@ export class AdminOrgsService {
182
185
  type: 'organization.member_added',
183
186
  actorId: actor.actorId,
184
187
  ip: actor.ip,
188
+ orgId,
185
189
  metadata: {
186
190
  orgId,
187
191
  accountId: input.accountId,
@@ -209,6 +213,7 @@ export class AdminOrgsService {
209
213
  type: 'organization.member_removed',
210
214
  actorId: actor.actorId,
211
215
  ip: actor.ip,
216
+ orgId,
212
217
  metadata: {
213
218
  orgId,
214
219
  targetAccountId: accountId,
@@ -239,6 +244,7 @@ export class AdminOrgsService {
239
244
  type: 'organization.member_role_changed',
240
245
  actorId: actor.actorId,
241
246
  ip: actor.ip,
247
+ orgId,
242
248
  metadata: {
243
249
  orgId,
244
250
  targetAccountId: accountId,
@@ -301,6 +307,7 @@ export class AdminOrgsService {
301
307
  type: 'organization.invitation_sent',
302
308
  actorId: actor.actorId,
303
309
  ip: actor.ip,
310
+ orgId,
304
311
  metadata: {
305
312
  orgId,
306
313
  email: input.email,
@@ -327,6 +334,7 @@ export class AdminOrgsService {
327
334
  type: 'organization.invitation_revoked',
328
335
  actorId: actor.actorId,
329
336
  ip: actor.ip,
337
+ orgId,
330
338
  metadata: {
331
339
  orgId,
332
340
  invitationId,