@adonis-agora/authkit-server 0.78.0 → 0.80.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 +1 -0
- package/build/index.js +1 -0
- package/build/src/agents/config.d.ts +2 -0
- package/build/src/agents/config.js +3 -0
- package/build/src/agents/delegation_store.d.ts +16 -0
- package/build/src/agents/delegation_store.js +44 -0
- package/build/src/agents/poppy/config.d.ts +3 -0
- package/build/src/agents/poppy/config.js +7 -0
- package/build/src/agents/poppy/email_code_sign_in.d.ts +27 -0
- package/build/src/agents/poppy/email_code_sign_in.js +140 -0
- package/build/src/agents/poppy/service.d.ts +6 -0
- package/build/src/agents/poppy/service.js +12 -0
- package/build/src/agents/poppy/store.d.ts +2 -0
- package/build/src/agents/poppy/store.js +9 -0
- package/build/src/agents/registry_store.d.ts +41 -0
- package/build/src/agents/registry_store.js +138 -0
- package/build/src/agents/runtime.d.ts +13 -0
- package/build/src/agents/runtime.js +87 -5
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +6 -0
- package/build/src/define_config.d.ts +12 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_console/admin_shell_controller.js +7 -0
- package/build/src/host/admin_console/console_personal_agents_controller.d.ts +83 -0
- package/build/src/host/admin_console/console_personal_agents_controller.js +337 -0
- package/build/src/host/default_mailer.d.ts +11 -0
- package/build/src/host/default_mailer.js +29 -0
- package/build/src/host/i18n.d.ts +10 -0
- package/build/src/host/i18n.js +12 -0
- package/build/src/host/register_auth_host.js +16 -0
- package/build/src/host/ui-dist/assets/index-LrScaR9C.js +155 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/schema/ensure.js +28 -0
- package/package.json +2 -2
- package/build/src/host/ui-dist/assets/index-CIqtoNcO.js +0 -155
|
@@ -8,6 +8,7 @@ import { hmacNonceSource } from './poppy/dpop.js';
|
|
|
8
8
|
import { databaseReplayStore, memoryLimiter, memoryReplayStore, redisReplayStore, } from './poppy/replay.js';
|
|
9
9
|
import { PoppyService, poppyUrls } from './poppy/service.js';
|
|
10
10
|
import { PoppyStore } from './poppy/store.js';
|
|
11
|
+
import { AgentRegistryStore } from './registry_store.js';
|
|
11
12
|
import { keystoreSigner } from './signer.js';
|
|
12
13
|
/**
|
|
13
14
|
* Deriva as URLs da ORIGEM do issuer configurado — nunca do Host da request
|
|
@@ -92,6 +93,41 @@ async function resolveReplay(replay, conn, make) {
|
|
|
92
93
|
const redis = await make('redis');
|
|
93
94
|
return redisReplayStore(redis.connection(name));
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Config com o registro do console por cima: um agente BLOQUEADO no console é
|
|
98
|
+
* recusado mesmo que o config o liste; um LIBERADO entra mesmo fora da lista.
|
|
99
|
+
* O que o config bloqueia continua bloqueado.
|
|
100
|
+
*/
|
|
101
|
+
function withRegistry(config, registry) {
|
|
102
|
+
const resolveAgent = async (issuer) => {
|
|
103
|
+
const row = await registry.find('pact', issuer);
|
|
104
|
+
if (row?.status === 'blocked') {
|
|
105
|
+
return { issuer, jwksUri: row.jwksUri ?? '', name: row.name ?? undefined, enabled: false };
|
|
106
|
+
}
|
|
107
|
+
const configured = await config.resolveAgent(issuer);
|
|
108
|
+
if (configured)
|
|
109
|
+
return configured;
|
|
110
|
+
if (row?.status === 'allowed' && row.jwksUri) {
|
|
111
|
+
return { issuer, jwksUri: row.jwksUri, name: row.name ?? undefined };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
const poppy = config.poppy;
|
|
116
|
+
return {
|
|
117
|
+
...config,
|
|
118
|
+
resolveAgent,
|
|
119
|
+
poppy: poppy && {
|
|
120
|
+
...poppy,
|
|
121
|
+
clients: {
|
|
122
|
+
...poppy.clients,
|
|
123
|
+
isBlocked: async (id) => (await poppy.clients.isBlocked(id)) ||
|
|
124
|
+
(await registry.find('poppy', id))?.status === 'blocked',
|
|
125
|
+
isRegistered: async (id) => (await poppy.clients.isRegistered(id)) ||
|
|
126
|
+
(await registry.find('poppy', id))?.status === 'allowed',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
95
131
|
/**
|
|
96
132
|
* Monta (uma vez por `OidcService`) o runtime de personal agents. `null` quando
|
|
97
133
|
* `personalAgents` não está no config — as rotas respondem 404.
|
|
@@ -112,12 +148,25 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
112
148
|
return !(supportsAccountStatus(store) && (await store.isDisabled(accountId)));
|
|
113
149
|
};
|
|
114
150
|
let conn = null;
|
|
151
|
+
const connection = service.config.schema.connection;
|
|
115
152
|
if (config.delegation || config.poppy) {
|
|
116
153
|
const db = await makeDb();
|
|
117
|
-
const connection = service.config.schema.connection;
|
|
118
154
|
conn = () => (connection ? db.connection(connection) : db.connection());
|
|
119
155
|
}
|
|
156
|
+
else {
|
|
157
|
+
// PACT só de identidade não precisa de banco; com ele, o console pode bloquear.
|
|
158
|
+
try {
|
|
159
|
+
const db = await makeDb();
|
|
160
|
+
if (db)
|
|
161
|
+
conn = () => (connection ? db.connection(connection) : db.connection());
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
conn = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
120
167
|
const store = conn ? new DelegationStore(conn) : null;
|
|
168
|
+
const registry = conn ? new AgentRegistryStore(conn) : null;
|
|
169
|
+
const effective = registry ? withRegistry(config, registry) : config;
|
|
121
170
|
let delegation = null;
|
|
122
171
|
if (config.delegation && store) {
|
|
123
172
|
delegation = new PersonalAgentDelegation({
|
|
@@ -129,8 +178,8 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
129
178
|
});
|
|
130
179
|
}
|
|
131
180
|
let poppy = null;
|
|
132
|
-
if (
|
|
133
|
-
const cfg =
|
|
181
|
+
if (effective.poppy && store && conn) {
|
|
182
|
+
const cfg = effective.poppy;
|
|
134
183
|
const limiter = cfg.rateLimit === false
|
|
135
184
|
? null
|
|
136
185
|
: typeof cfg.rateLimit === 'function'
|
|
@@ -165,14 +214,47 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
165
214
|
count += await delegation.revokeAllGrants(accountId);
|
|
166
215
|
return count;
|
|
167
216
|
};
|
|
217
|
+
const revokeGrantById = async (grantId) => {
|
|
218
|
+
if (!store)
|
|
219
|
+
return null;
|
|
220
|
+
const grant = await store.findGrant(grantId);
|
|
221
|
+
const now = options.now?.() ?? new Date();
|
|
222
|
+
// Expirado já não vale: revogar não muda nada (e não vira evento de auditoria).
|
|
223
|
+
if (!grant || grant.revokedAt || grant.expiresAt <= now)
|
|
224
|
+
return null;
|
|
225
|
+
if (grantId.startsWith('pgrant_')) {
|
|
226
|
+
if (!poppy)
|
|
227
|
+
return null;
|
|
228
|
+
await poppy.revokeGrant(grantId);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
await store.revokeGrantById(grantId, now);
|
|
232
|
+
}
|
|
233
|
+
return grant;
|
|
234
|
+
};
|
|
235
|
+
const disconnectAgent = async (protocol, identifier) => {
|
|
236
|
+
if (!store)
|
|
237
|
+
return 0;
|
|
238
|
+
if (protocol === 'poppy')
|
|
239
|
+
return poppy ? poppy.disconnectClient(identifier) : 0;
|
|
240
|
+
const ids = (await store.unrevokedGrantIdsOfClient(identifier)).filter((id) => !id.startsWith('pgrant_'));
|
|
241
|
+
const at = options.now?.() ?? new Date();
|
|
242
|
+
for (const id of ids)
|
|
243
|
+
await store.revokeGrantById(id, at);
|
|
244
|
+
return ids.length;
|
|
245
|
+
};
|
|
168
246
|
return {
|
|
169
|
-
config,
|
|
247
|
+
config: effective,
|
|
170
248
|
urls,
|
|
171
|
-
verifier: new PersonalAgentVerifier(
|
|
249
|
+
verifier: new PersonalAgentVerifier(effective),
|
|
172
250
|
delegation,
|
|
173
251
|
poppy,
|
|
174
252
|
revokeAgentGrant,
|
|
175
253
|
revokeAllAgentGrants,
|
|
254
|
+
registry,
|
|
255
|
+
grants: store,
|
|
256
|
+
revokeGrantById,
|
|
257
|
+
disconnectAgent,
|
|
176
258
|
};
|
|
177
259
|
})();
|
|
178
260
|
// Falha (DB indisponível) não fica em cache — a próxima request tenta de novo.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* varre os `audit.record({ type: ... })` do `src/` e falha quando um evento
|
|
9
9
|
* emitido não está aqui.
|
|
10
10
|
*/
|
|
11
|
-
export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'mfa.recovery_codes_regenerated', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated', 'agent.delegation_approved', 'agent.delegation_denied', 'agent.grant_revoked'];
|
|
11
|
+
export declare const AUDIT_EVENT_TYPES: readonly ['login.success', 'login.failure', 'signup', 'password_reset.issued', 'password_reset.consumed', 'pat.issued', 'pat.revoked', 'pat.used', 'impersonation', 'impersonation.panel_viewed', 'impersonation.stopped', 'mfa.enabled', 'mfa.disabled', 'mfa.recovery_codes_regenerated', 'account.locked', 'passkey.registered', 'passkey.removed', 'email_verification.issued', 'email_verification.consumed', 'client.created', 'client.updated', 'client.deleted', 'session.revoked_all', 'password.changed', 'password.rehashed', 'email.change_requested', 'email.changed', 'login.new_ip_notified', 'login.new_device', 'login.otp_sent', 'login.otp_verified', 'login.otp_failed', 'login.otp_invalidated', 'bot_protection.rejected', 'grant.revoked_by_user', 'user.created', 'user.password_reset_sent', 'user.disabled', 'user.enabled', 'user.deleted', 'profile.updated', 'account.deleted', 'account.exported', 'keys.rotated', 'organization.created', 'organization.updated', 'organization.deleted', 'organization.member_added', 'organization.member_removed', 'organization.member_role_changed', 'organization.member_role_updated', 'organization.switched', 'organization.deactivated', 'organization.invitation_sent', 'organization.invitation_accepted', 'organization.invitation_revoked', 'email_change.requested', 'email_change.confirmed', 'email_change.cancelled', 'security_notice.sent', 'settings.updated', 'maintenance.enabled', 'maintenance.disabled', 'trusted_device.revoked', 'password.expired_change_forced', 'otp.locked', 'otp.unlocked', 'otp.unlock_failed', 'sudo.confirmed', 'session.single_enforced', 'account.expired_login_blocked', 'account.expiration_warned', 'login.magic_link_sent', 'session.revoked', 'account.signed_out_all', 'client.secret_regenerated', 'roles_catalog.updated', 'agent.delegation_approved', 'agent.delegation_denied', 'agent.grant_revoked', 'agent.grant_revoked_by_admin', 'agent.allowed', 'agent.blocked', 'agent.registry_removed'];
|
|
12
12
|
/** Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP. */
|
|
13
13
|
export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[number];
|
|
14
14
|
/**
|
|
@@ -113,4 +113,10 @@ export const AUDIT_EVENT_TYPES = [
|
|
|
113
113
|
'agent.delegation_approved',
|
|
114
114
|
'agent.delegation_denied',
|
|
115
115
|
'agent.grant_revoked',
|
|
116
|
+
// Console admin: um admin revogou a conexão de um usuário, ou liberou/bloqueou um
|
|
117
|
+
// agente (PACT pelo `iss`, Poppy pelo `client_id`).
|
|
118
|
+
'agent.grant_revoked_by_admin',
|
|
119
|
+
'agent.allowed',
|
|
120
|
+
'agent.blocked',
|
|
121
|
+
'agent.registry_removed',
|
|
116
122
|
];
|
|
@@ -78,6 +78,18 @@ export interface MailHooks {
|
|
|
78
78
|
email: string;
|
|
79
79
|
sudoUrl: string;
|
|
80
80
|
}) => Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Código do Mediated Sign-In pronto do Poppy (`poppyEmailCodeSignIn`): um personal
|
|
83
|
+
* agent quer entrar na conta e a pessoa precisa dar o código a ele. Sem o hook, o
|
|
84
|
+
* host-kit envia pelo mailer default, com branding e i18n.
|
|
85
|
+
*/
|
|
86
|
+
onPoppySignInCode?: (data: {
|
|
87
|
+
email: string;
|
|
88
|
+
code: string;
|
|
89
|
+
/** Nome do agente (metadata dele, ou o host do `client_id`). */
|
|
90
|
+
agentName: string;
|
|
91
|
+
expiresInMinutes: number;
|
|
92
|
+
}) => Promise<void>;
|
|
81
93
|
/**
|
|
82
94
|
* Disparado num login bem-sucedido a partir de um dispositivo NOVO (sem cookie
|
|
83
95
|
* de dispositivo confiável válido para a conta). Best-effort, fire-and-forget:
|
|
@@ -49,7 +49,7 @@ export declare function grantDto(grant: AdminGrant): {
|
|
|
49
49
|
};
|
|
50
50
|
export declare function auditDto(event: StoredAuditEvent): {
|
|
51
51
|
id: string;
|
|
52
|
-
type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "agent.delegation_approved" | "agent.delegation_denied" | "agent.grant_revoked" | "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
|
+
type: "account.deleted" | "account.expiration_warned" | "account.expired_login_blocked" | "account.exported" | "account.locked" | "account.signed_out_all" | "agent.allowed" | "agent.blocked" | "agent.delegation_approved" | "agent.delegation_denied" | "agent.grant_revoked" | "agent.grant_revoked_by_admin" | "agent.registry_removed" | "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";
|
|
53
53
|
accountId: string | null;
|
|
54
54
|
email: string | null;
|
|
55
55
|
clientId: string | null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { extname } from 'node:path';
|
|
3
|
+
import { accountPath } from '../account_paths.js';
|
|
3
4
|
import { resolveAccountRoles } from '../account_roles.js';
|
|
4
5
|
import { getAdminPrefix } from '../admin_prefix.js';
|
|
5
6
|
// ─── Shell HTML cache ─────────────────────────────────────────────────────────
|
|
@@ -134,6 +135,12 @@ export default class AdminShellController {
|
|
|
134
135
|
currentUser,
|
|
135
136
|
endpoints: {
|
|
136
137
|
api: `${adminBase}/api`,
|
|
138
|
+
// Confirmação de identidade (sudo) das ações destrutivas.
|
|
139
|
+
confirm: accountPath('confirm'),
|
|
140
|
+
},
|
|
141
|
+
features: {
|
|
142
|
+
// A página de personal agents só aparece com `personalAgents` no config.
|
|
143
|
+
personalAgents: !!cfg.personalAgents,
|
|
137
144
|
},
|
|
138
145
|
};
|
|
139
146
|
const { html: rawHtml } = await loadShell();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
import type { AgentProtocolId } from '../../agents/registry_store.js';
|
|
4
|
+
type ListStatus = 'allowed' | 'blocked';
|
|
5
|
+
interface AgentEntry {
|
|
6
|
+
protocol: AgentProtocolId;
|
|
7
|
+
identifier: string;
|
|
8
|
+
name: string;
|
|
9
|
+
/** O que o `defineConfig` diz (só listas estáticas); `null` = nada. */
|
|
10
|
+
config: ListStatus | null;
|
|
11
|
+
/** O que o console diz; `null` = nada. */
|
|
12
|
+
console: {
|
|
13
|
+
id: string;
|
|
14
|
+
status: ListStatus;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
updatedBy: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/** Resultado: bloqueado em qualquer lado vence; senão liberado em qualquer lado; senão `unlisted`. */
|
|
19
|
+
effective: ListStatus | 'unlisted';
|
|
20
|
+
jwksUri: string | null;
|
|
21
|
+
/** Conexões vivas (grants não revogados nem expirados). */
|
|
22
|
+
connections: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Endpoints JSON de personal agents (PACT e Poppy) do console admin React.
|
|
26
|
+
*
|
|
27
|
+
* GET {prefix}/api/personal-agents → agentes (config + console + conectados)
|
|
28
|
+
* GET {prefix}/api/personal-agents/connections?clientId=&accountId=&protocol=&page=&size=
|
|
29
|
+
* POST {prefix}/api/personal-agents/connections/:grantId/revoke (sudo)
|
|
30
|
+
* POST {prefix}/api/personal-agents/registry → libera/bloqueia (sudo)
|
|
31
|
+
* DELETE {prefix}/api/personal-agents/registry/:id → tira do registro do console (sudo)
|
|
32
|
+
*
|
|
33
|
+
* 404 `capability_unsupported` quando `personalAgents` não está configurado ou não há banco.
|
|
34
|
+
*/
|
|
35
|
+
export default class ConsolePersonalAgentsController {
|
|
36
|
+
/** GET {prefix}/api/personal-agents */
|
|
37
|
+
index(ctx: HttpContext): Promise<void | {
|
|
38
|
+
protocols: {
|
|
39
|
+
pact: {
|
|
40
|
+
open: boolean;
|
|
41
|
+
/** A lista do config é uma função do host (o console não enxerga). */
|
|
42
|
+
hostResolver: boolean;
|
|
43
|
+
} | null;
|
|
44
|
+
poppy: {
|
|
45
|
+
requireRegistration: boolean;
|
|
46
|
+
hostResolver: boolean;
|
|
47
|
+
allowInsecureClientIds: boolean;
|
|
48
|
+
} | null;
|
|
49
|
+
};
|
|
50
|
+
agents: AgentEntry[];
|
|
51
|
+
}>;
|
|
52
|
+
/** GET {prefix}/api/personal-agents/connections */
|
|
53
|
+
connections(ctx: HttpContext): Promise<void | {
|
|
54
|
+
meta: {
|
|
55
|
+
page: number;
|
|
56
|
+
size: number;
|
|
57
|
+
total: number;
|
|
58
|
+
};
|
|
59
|
+
data: {
|
|
60
|
+
id: string;
|
|
61
|
+
protocol: AgentProtocolId;
|
|
62
|
+
clientId: string;
|
|
63
|
+
agentName: string;
|
|
64
|
+
accountId: string;
|
|
65
|
+
accountEmail: string | null;
|
|
66
|
+
scopes: string[];
|
|
67
|
+
createdAt: string;
|
|
68
|
+
updatedAt: string;
|
|
69
|
+
expiresAt: string;
|
|
70
|
+
}[];
|
|
71
|
+
}>;
|
|
72
|
+
/** POST {prefix}/api/personal-agents/connections/:grantId/revoke */
|
|
73
|
+
revokeConnection(ctx: HttpContext): Promise<void | {}>;
|
|
74
|
+
/**
|
|
75
|
+
* POST {prefix}/api/personal-agents/registry
|
|
76
|
+
* body: `{ protocol, identifier, status: 'allowed'|'blocked', name?, jwksUri? }`.
|
|
77
|
+
* Bloquear também desconecta o agente de todas as contas, na hora.
|
|
78
|
+
*/
|
|
79
|
+
upsert(ctx: HttpContext): Promise<void | {}>;
|
|
80
|
+
/** DELETE {prefix}/api/personal-agents/registry/:id */
|
|
81
|
+
destroy(ctx: HttpContext): Promise<void | {}>;
|
|
82
|
+
}
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import '../augmentations.js';
|
|
2
|
+
import { displayNameFor } from '../../agents/agent_identity.js';
|
|
3
|
+
import { parseScope } from '../../agents/config.js';
|
|
4
|
+
import { personalAgentsFor } from '../../agents/runtime.js';
|
|
5
|
+
import { parseListPage, parseListSize } from '../../pagination.js';
|
|
6
|
+
import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
|
|
7
|
+
import { apiError } from '../admin_api/dto.js';
|
|
8
|
+
import { resolveRuntimeSettings } from '../runtime_settings.js';
|
|
9
|
+
import { requireSudo } from '../sudo_mode.js';
|
|
10
|
+
/** Mesmo gate de sudo das outras ações destrutivas do console (sessões, chaves). */
|
|
11
|
+
async function gateSudo(ctx) {
|
|
12
|
+
const settings = await resolveRuntimeSettings(ctx);
|
|
13
|
+
const result = await requireSudo(ctx, settings);
|
|
14
|
+
if (result === true)
|
|
15
|
+
return null;
|
|
16
|
+
return ctx.response
|
|
17
|
+
.status(403)
|
|
18
|
+
.send(apiError('sudo_required', 'Identity confirmation required.'));
|
|
19
|
+
}
|
|
20
|
+
function notSupported(ctx) {
|
|
21
|
+
return ctx.response.notFound(apiError('capability_unsupported', 'Personal agents não estão configurados (`personalAgents` no defineConfig) ou não há banco.'));
|
|
22
|
+
}
|
|
23
|
+
/** O runtime com banco, ou `null` (404 honesto). */
|
|
24
|
+
async function runtimeFor(ctx) {
|
|
25
|
+
const runtime = await personalAgentsFor(ctx);
|
|
26
|
+
if (!runtime?.registry || !runtime.grants)
|
|
27
|
+
return null;
|
|
28
|
+
return runtime;
|
|
29
|
+
}
|
|
30
|
+
function enabledProtocols(runtime) {
|
|
31
|
+
const out = [];
|
|
32
|
+
if (runtime.config.pact)
|
|
33
|
+
out.push('pact');
|
|
34
|
+
if (runtime.config.poppy)
|
|
35
|
+
out.push('poppy');
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function isUrl(value, allowHttp) {
|
|
39
|
+
if (typeof value !== 'string' || value.length > 2048)
|
|
40
|
+
return false;
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(value);
|
|
43
|
+
return ((url.protocol === 'https:' || (allowHttp && url.protocol === 'http:')) &&
|
|
44
|
+
!url.hash &&
|
|
45
|
+
!url.username &&
|
|
46
|
+
!url.password);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function agentName(runtime, protocol, identifier, fallback) {
|
|
53
|
+
if (fallback)
|
|
54
|
+
return fallback;
|
|
55
|
+
if (protocol === 'poppy' && runtime.poppy) {
|
|
56
|
+
return runtime.poppy.registry.displayName(identifier);
|
|
57
|
+
}
|
|
58
|
+
const registration = await runtime.config.resolveAgent(identifier);
|
|
59
|
+
return displayNameFor(registration ?? { issuer: identifier, jwksUri: '' });
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Endpoints JSON de personal agents (PACT e Poppy) do console admin React.
|
|
63
|
+
*
|
|
64
|
+
* GET {prefix}/api/personal-agents → agentes (config + console + conectados)
|
|
65
|
+
* GET {prefix}/api/personal-agents/connections?clientId=&accountId=&protocol=&page=&size=
|
|
66
|
+
* POST {prefix}/api/personal-agents/connections/:grantId/revoke (sudo)
|
|
67
|
+
* POST {prefix}/api/personal-agents/registry → libera/bloqueia (sudo)
|
|
68
|
+
* DELETE {prefix}/api/personal-agents/registry/:id → tira do registro do console (sudo)
|
|
69
|
+
*
|
|
70
|
+
* 404 `capability_unsupported` quando `personalAgents` não está configurado ou não há banco.
|
|
71
|
+
*/
|
|
72
|
+
export default class ConsolePersonalAgentsController {
|
|
73
|
+
/** GET {prefix}/api/personal-agents */
|
|
74
|
+
async index(ctx) {
|
|
75
|
+
const runtime = await runtimeFor(ctx);
|
|
76
|
+
if (!runtime)
|
|
77
|
+
return notSupported(ctx);
|
|
78
|
+
const cfg = runtime.config;
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const entries = new Map();
|
|
81
|
+
const keyOf = (p, id) => `${p} ${id}`;
|
|
82
|
+
const entry = (protocol, identifier) => {
|
|
83
|
+
const k = keyOf(protocol, identifier);
|
|
84
|
+
let e = entries.get(k);
|
|
85
|
+
if (!e) {
|
|
86
|
+
e = {
|
|
87
|
+
protocol,
|
|
88
|
+
identifier,
|
|
89
|
+
name: '',
|
|
90
|
+
config: null,
|
|
91
|
+
console: null,
|
|
92
|
+
effective: 'unlisted',
|
|
93
|
+
jwksUri: null,
|
|
94
|
+
connections: 0,
|
|
95
|
+
};
|
|
96
|
+
entries.set(k, e);
|
|
97
|
+
}
|
|
98
|
+
return e;
|
|
99
|
+
};
|
|
100
|
+
const names = new Map();
|
|
101
|
+
if (cfg.pact) {
|
|
102
|
+
for (const agent of cfg.staticAgents ?? []) {
|
|
103
|
+
const e = entry('pact', agent.issuer);
|
|
104
|
+
e.config = agent.enabled === false ? 'blocked' : 'allowed';
|
|
105
|
+
e.jwksUri = agent.jwksUri;
|
|
106
|
+
names.set(keyOf('pact', agent.issuer), agent.name ?? null);
|
|
107
|
+
}
|
|
108
|
+
for (const [clientId, count] of await runtime.grants.countLiveGrantsByClient(now, 'pact')) {
|
|
109
|
+
entry('pact', clientId).connections = count;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (cfg.poppy) {
|
|
113
|
+
for (const id of cfg.poppy.clients.registeredList ?? [])
|
|
114
|
+
entry('poppy', id).config = 'allowed';
|
|
115
|
+
for (const id of cfg.poppy.clients.blockedList ?? [])
|
|
116
|
+
entry('poppy', id).config = 'blocked';
|
|
117
|
+
for (const [clientId, count] of await runtime.grants.countLiveGrantsByClient(now, 'poppy')) {
|
|
118
|
+
entry('poppy', clientId).connections = count;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const row of await runtime.registry.list()) {
|
|
122
|
+
if (!enabledProtocols(runtime).includes(row.protocol))
|
|
123
|
+
continue;
|
|
124
|
+
const e = entry(row.protocol, row.identifier);
|
|
125
|
+
e.console = {
|
|
126
|
+
id: row.id,
|
|
127
|
+
status: row.status,
|
|
128
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
129
|
+
updatedBy: row.updatedBy,
|
|
130
|
+
};
|
|
131
|
+
if (row.jwksUri)
|
|
132
|
+
e.jwksUri = row.jwksUri;
|
|
133
|
+
if (row.name)
|
|
134
|
+
names.set(keyOf(row.protocol, row.identifier), row.name);
|
|
135
|
+
}
|
|
136
|
+
const agents = await Promise.all([...entries.values()].map(async (e) => {
|
|
137
|
+
const statuses = [e.config, e.console?.status];
|
|
138
|
+
e.effective = statuses.includes('blocked')
|
|
139
|
+
? 'blocked'
|
|
140
|
+
: statuses.includes('allowed')
|
|
141
|
+
? 'allowed'
|
|
142
|
+
: 'unlisted';
|
|
143
|
+
e.name = await agentName(runtime, e.protocol, e.identifier, names.get(keyOf(e.protocol, e.identifier)) ?? null);
|
|
144
|
+
return e;
|
|
145
|
+
}));
|
|
146
|
+
agents.sort((a, b) => b.connections - a.connections || a.name.localeCompare(b.name));
|
|
147
|
+
return {
|
|
148
|
+
protocols: {
|
|
149
|
+
pact: cfg.pact
|
|
150
|
+
? {
|
|
151
|
+
open: cfg.open,
|
|
152
|
+
/** A lista do config é uma função do host (o console não enxerga). */
|
|
153
|
+
hostResolver: cfg.staticAgents === null,
|
|
154
|
+
}
|
|
155
|
+
: null,
|
|
156
|
+
poppy: cfg.poppy
|
|
157
|
+
? {
|
|
158
|
+
requireRegistration: cfg.poppy.clients.requireRegistration,
|
|
159
|
+
hostResolver: cfg.poppy.clients.registeredList === null || cfg.poppy.clients.blockedList === null,
|
|
160
|
+
allowInsecureClientIds: cfg.poppy.allowInsecureClientIds,
|
|
161
|
+
}
|
|
162
|
+
: null,
|
|
163
|
+
},
|
|
164
|
+
agents,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** GET {prefix}/api/personal-agents/connections */
|
|
168
|
+
async connections(ctx) {
|
|
169
|
+
const runtime = await runtimeFor(ctx);
|
|
170
|
+
if (!runtime)
|
|
171
|
+
return notSupported(ctx);
|
|
172
|
+
const qs = ctx.request.qs();
|
|
173
|
+
const page = parseListPage(qs.page);
|
|
174
|
+
const size = parseListSize(qs.size);
|
|
175
|
+
const protocol = qs.protocol === 'pact' || qs.protocol === 'poppy'
|
|
176
|
+
? qs.protocol
|
|
177
|
+
: undefined;
|
|
178
|
+
const filter = {
|
|
179
|
+
protocol,
|
|
180
|
+
clientId: qs.clientId?.trim() || undefined,
|
|
181
|
+
accountId: qs.accountId?.trim() || undefined,
|
|
182
|
+
};
|
|
183
|
+
const now = new Date();
|
|
184
|
+
const [total, rows] = await Promise.all([
|
|
185
|
+
runtime.grants.countLiveGrants(now, filter),
|
|
186
|
+
runtime.grants.listLiveGrants({ ...filter, now, offset: (page - 1) * size, limit: size }),
|
|
187
|
+
]);
|
|
188
|
+
const store = (await ctx.containerResolver.make('authkit.server')).config.accountStore;
|
|
189
|
+
const emails = new Map();
|
|
190
|
+
const nameCache = new Map();
|
|
191
|
+
const data = await Promise.all(rows.map(async (g) => {
|
|
192
|
+
const rowProtocol = g.id.startsWith('pgrant_') ? 'poppy' : 'pact';
|
|
193
|
+
if (!emails.has(g.accountId)) {
|
|
194
|
+
const account = await store.findById(g.accountId).catch(() => null);
|
|
195
|
+
emails.set(g.accountId, account?.email ?? null);
|
|
196
|
+
}
|
|
197
|
+
const k = `${rowProtocol} ${g.clientId}`;
|
|
198
|
+
if (!nameCache.has(k))
|
|
199
|
+
nameCache.set(k, agentName(runtime, rowProtocol, g.clientId, null));
|
|
200
|
+
return {
|
|
201
|
+
id: g.id,
|
|
202
|
+
protocol: rowProtocol,
|
|
203
|
+
clientId: g.clientId,
|
|
204
|
+
agentName: await nameCache.get(k),
|
|
205
|
+
accountId: g.accountId,
|
|
206
|
+
accountEmail: emails.get(g.accountId) ?? null,
|
|
207
|
+
scopes: parseScope(g.scope),
|
|
208
|
+
createdAt: g.createdAt.toISOString(),
|
|
209
|
+
updatedAt: g.updatedAt.toISOString(),
|
|
210
|
+
expiresAt: g.expiresAt.toISOString(),
|
|
211
|
+
};
|
|
212
|
+
}));
|
|
213
|
+
return { meta: { page, size, total }, data };
|
|
214
|
+
}
|
|
215
|
+
/** POST {prefix}/api/personal-agents/connections/:grantId/revoke */
|
|
216
|
+
async revokeConnection(ctx) {
|
|
217
|
+
const runtime = await runtimeFor(ctx);
|
|
218
|
+
if (!runtime)
|
|
219
|
+
return notSupported(ctx);
|
|
220
|
+
const denied = await gateSudo(ctx);
|
|
221
|
+
if (denied)
|
|
222
|
+
return denied;
|
|
223
|
+
const grantId = ctx.request.param('grantId');
|
|
224
|
+
const grant = await runtime.revokeGrantById(grantId);
|
|
225
|
+
if (!grant) {
|
|
226
|
+
return ctx.response.notFound(apiError('not_found', 'Conexão não encontrada ou já revogada.'));
|
|
227
|
+
}
|
|
228
|
+
const cfg = (await ctx.containerResolver.make('authkit.server')).config;
|
|
229
|
+
await cfg.audit?.record({
|
|
230
|
+
type: 'agent.grant_revoked_by_admin',
|
|
231
|
+
accountId: grant.accountId,
|
|
232
|
+
clientId: grant.clientId,
|
|
233
|
+
ip: ctx.request.ip?.() ?? null,
|
|
234
|
+
metadata: { grantId, actor: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null },
|
|
235
|
+
});
|
|
236
|
+
return { revoked: true };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* POST {prefix}/api/personal-agents/registry
|
|
240
|
+
* body: `{ protocol, identifier, status: 'allowed'|'blocked', name?, jwksUri? }`.
|
|
241
|
+
* Bloquear também desconecta o agente de todas as contas, na hora.
|
|
242
|
+
*/
|
|
243
|
+
async upsert(ctx) {
|
|
244
|
+
const runtime = await runtimeFor(ctx);
|
|
245
|
+
if (!runtime)
|
|
246
|
+
return notSupported(ctx);
|
|
247
|
+
const body = (ctx.request.body() ?? {});
|
|
248
|
+
const invalid = (message) => ctx.response.unprocessableEntity(apiError('invalid_request', message));
|
|
249
|
+
const protocol = body.protocol;
|
|
250
|
+
if (!enabledProtocols(runtime).includes(protocol)) {
|
|
251
|
+
return invalid('`protocol` precisa ser um protocolo ligado (`pact` ou `poppy`).');
|
|
252
|
+
}
|
|
253
|
+
const status = body.status;
|
|
254
|
+
if (status !== 'allowed' && status !== 'blocked') {
|
|
255
|
+
return invalid('`status` precisa ser `allowed` ou `blocked`.');
|
|
256
|
+
}
|
|
257
|
+
const allowHttp = protocol === 'poppy' && !!runtime.config.poppy?.allowInsecureClientIds;
|
|
258
|
+
const identifier = typeof body.identifier === 'string' ? body.identifier.trim() : '';
|
|
259
|
+
if (!isUrl(identifier, allowHttp)) {
|
|
260
|
+
return invalid(protocol === 'poppy'
|
|
261
|
+
? '`identifier` precisa ser o `client_id` do agente (URL HTTPS do documento de metadata).'
|
|
262
|
+
: '`identifier` precisa ser o `iss` do agente (URL HTTPS).');
|
|
263
|
+
}
|
|
264
|
+
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 200) : null;
|
|
265
|
+
let jwksUri = null;
|
|
266
|
+
if (protocol === 'pact') {
|
|
267
|
+
const raw = typeof body.jwksUri === 'string' ? body.jwksUri.trim() : '';
|
|
268
|
+
if (raw && !isUrl(raw, false))
|
|
269
|
+
return invalid('`jwksUri` precisa ser uma URL HTTPS.');
|
|
270
|
+
jwksUri = raw || null;
|
|
271
|
+
if (status === 'allowed' && !jwksUri) {
|
|
272
|
+
return invalid('Liberar um agente PACT exige o `jwksUri` dele.');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const denied = await gateSudo(ctx);
|
|
276
|
+
if (denied)
|
|
277
|
+
return denied;
|
|
278
|
+
const actor = ctx.session.get(ACCOUNT_SESSION_KEY) ?? null;
|
|
279
|
+
const row = await runtime.registry.upsert({
|
|
280
|
+
protocol,
|
|
281
|
+
identifier,
|
|
282
|
+
status,
|
|
283
|
+
name,
|
|
284
|
+
jwksUri,
|
|
285
|
+
updatedBy: actor,
|
|
286
|
+
});
|
|
287
|
+
const disconnected = status === 'blocked' ? await runtime.disconnectAgent(protocol, identifier) : 0;
|
|
288
|
+
const cfg = (await ctx.containerResolver.make('authkit.server')).config;
|
|
289
|
+
const event = {
|
|
290
|
+
clientId: identifier,
|
|
291
|
+
ip: ctx.request.ip?.() ?? null,
|
|
292
|
+
metadata: { protocol, actor, disconnected },
|
|
293
|
+
};
|
|
294
|
+
if (status === 'blocked')
|
|
295
|
+
await cfg.audit?.record({ type: 'agent.blocked', ...event });
|
|
296
|
+
else
|
|
297
|
+
await cfg.audit?.record({ type: 'agent.allowed', ...event });
|
|
298
|
+
return {
|
|
299
|
+
entry: {
|
|
300
|
+
id: row.id,
|
|
301
|
+
protocol: row.protocol,
|
|
302
|
+
identifier: row.identifier,
|
|
303
|
+
status: row.status,
|
|
304
|
+
name: row.name,
|
|
305
|
+
jwksUri: row.jwksUri,
|
|
306
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
307
|
+
},
|
|
308
|
+
disconnected,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** DELETE {prefix}/api/personal-agents/registry/:id */
|
|
312
|
+
async destroy(ctx) {
|
|
313
|
+
const runtime = await runtimeFor(ctx);
|
|
314
|
+
if (!runtime)
|
|
315
|
+
return notSupported(ctx);
|
|
316
|
+
const denied = await gateSudo(ctx);
|
|
317
|
+
if (denied)
|
|
318
|
+
return denied;
|
|
319
|
+
const id = ctx.request.param('id');
|
|
320
|
+
const row = await runtime.registry.findById(id);
|
|
321
|
+
if (!row || !(await runtime.registry.delete(id))) {
|
|
322
|
+
return ctx.response.notFound(apiError('not_found', 'Entrada não encontrada.'));
|
|
323
|
+
}
|
|
324
|
+
const cfg = (await ctx.containerResolver.make('authkit.server')).config;
|
|
325
|
+
await cfg.audit?.record({
|
|
326
|
+
type: 'agent.registry_removed',
|
|
327
|
+
clientId: row.identifier,
|
|
328
|
+
ip: ctx.request.ip?.() ?? null,
|
|
329
|
+
metadata: {
|
|
330
|
+
protocol: row.protocol,
|
|
331
|
+
status: row.status,
|
|
332
|
+
actor: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
return { removed: true };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -66,6 +66,17 @@ export declare function sendMagicLinkEmail(ctx: HttpContext, data: {
|
|
|
66
66
|
code?: string;
|
|
67
67
|
channel?: 'code' | 'link';
|
|
68
68
|
}): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Envia o código do Mediated Sign-In pronto do Poppy (`poppyEmailCodeSignIn`) pelo
|
|
71
|
+
* mailer default do host. Só código, sem link: quem digita é a pessoa, no agente.
|
|
72
|
+
* Best-effort: no fallback (sem mail) loga o código, como o magic link loga o link; nunca lança.
|
|
73
|
+
*/
|
|
74
|
+
export declare function sendPoppySignInCodeEmail(ctx: HttpContext, data: {
|
|
75
|
+
email: string;
|
|
76
|
+
code: string;
|
|
77
|
+
agentName: string;
|
|
78
|
+
expiresInMinutes: number;
|
|
79
|
+
}): Promise<void>;
|
|
69
80
|
/**
|
|
70
81
|
* Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo) pelo mailer default do host.
|
|
71
82
|
*
|