@adonis-agora/authkit-server 1.0.0-rc.1 → 1.0.0-rc.3
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/CHANGELOG.md +34 -0
- package/README.md +4 -5
- package/build/commands/clients_create.d.ts +4 -0
- package/build/commands/clients_create.js +97 -0
- package/build/commands/commands.json +14 -0
- package/build/index.d.ts +8 -11
- package/build/index.js +5 -9
- package/build/providers/authkit_server_provider.js +6 -8
- package/build/src/agents/config.d.ts +9 -13
- package/build/src/agents/config.js +17 -11
- package/build/src/agents/delegation_service.js +3 -3
- package/build/src/agents/delegation_store.d.ts +3 -2
- package/build/src/agents/delegation_store.js +6 -5
- package/build/src/agents/extension.d.ts +261 -0
- package/build/src/agents/extension.js +40 -0
- package/build/src/agents/middleware.js +1 -1
- package/build/src/agents/protocol.d.ts +4 -4
- package/build/src/agents/registry_store.d.ts +3 -2
- package/build/src/agents/registry_store.js +1 -1
- package/build/src/agents/runtime.d.ts +26 -7
- package/build/src/agents/runtime.js +110 -75
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +6 -1
- package/build/src/define_config.d.ts +16 -12
- package/build/src/define_config.js +11 -7
- package/build/src/host/access_token_verifier.js +7 -0
- package/build/src/host/account_api/account_api_controller.js +8 -6
- package/build/src/host/account_session_sign_in.d.ts +12 -0
- package/build/src/host/account_session_sign_in.js +19 -0
- package/build/src/host/admin_api/api_clients_controller.d.ts +37 -0
- package/build/src/host/admin_api/api_clients_controller.js +31 -1
- package/build/src/host/admin_api/dto.d.ts +3 -1
- package/build/src/host/admin_api/dto.js +2 -0
- package/build/src/host/admin_clients_service.d.ts +20 -0
- package/build/src/host/admin_clients_service.js +55 -0
- package/build/src/host/admin_console/console_clients_controller.d.ts +41 -0
- package/build/src/host/admin_console/console_clients_controller.js +34 -1
- package/build/src/host/admin_console/console_personal_agents_controller.d.ts +1 -6
- package/build/src/host/admin_console/console_personal_agents_controller.js +50 -30
- package/build/src/host/admin_sessions_service.js +8 -5
- package/build/src/host/admin_validators.d.ts +29 -0
- package/build/src/host/admin_validators.js +30 -0
- package/build/src/host/auth_host_config.d.ts +6 -2
- package/build/src/host/controllers/account_apps_controller.js +15 -11
- package/build/src/host/csrf.d.ts +3 -3
- package/build/src/host/csrf.js +3 -13
- package/build/src/host/default_mailer.d.ts +11 -6
- package/build/src/host/default_mailer.js +16 -12
- package/build/src/host/i18n.d.ts +3 -14
- package/build/src/host/i18n.js +15 -13
- package/build/src/host/register_auth_host.js +30 -61
- package/build/src/host/ui-dist/assets/index-u0bMxkLg.js +67 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/build_provider.js +37 -6
- package/build/src/provider/oidc_service.d.ts +1 -1
- package/build/src/provider/oidc_service.js +1 -1
- package/build/src/schema/ensure.d.ts +14 -0
- package/build/src/schema/ensure.js +43 -150
- package/build/src/service_tenants/client_metadata.d.ts +39 -0
- package/build/src/service_tenants/client_metadata.js +80 -0
- package/build/src/service_tenants/config.d.ts +44 -0
- package/build/src/service_tenants/config.js +44 -0
- package/build/src/service_tenants/provider_hooks.d.ts +52 -0
- package/build/src/service_tenants/provider_hooks.js +124 -0
- package/build/src/service_tenants/tenant_verifier.d.ts +73 -0
- package/build/src/service_tenants/tenant_verifier.js +104 -0
- package/package.json +2 -2
- package/build/src/agents/poppy/client_registry.d.ts +0 -50
- package/build/src/agents/poppy/client_registry.js +0 -260
- package/build/src/agents/poppy/config.d.ts +0 -396
- package/build/src/agents/poppy/config.js +0 -228
- package/build/src/agents/poppy/dpop.d.ts +0 -61
- package/build/src/agents/poppy/dpop.js +0 -158
- package/build/src/agents/poppy/email_code_sign_in.d.ts +0 -27
- package/build/src/agents/poppy/email_code_sign_in.js +0 -140
- package/build/src/agents/poppy/errors.d.ts +0 -25
- package/build/src/agents/poppy/errors.js +0 -25
- package/build/src/agents/poppy/replay.d.ts +0 -25
- package/build/src/agents/poppy/replay.js +0 -106
- package/build/src/agents/poppy/resource.d.ts +0 -100
- package/build/src/agents/poppy/resource.js +0 -129
- package/build/src/agents/poppy/safe_fetch.d.ts +0 -48
- package/build/src/agents/poppy/safe_fetch.js +0 -262
- package/build/src/agents/poppy/service.d.ts +0 -293
- package/build/src/agents/poppy/service.js +0 -1458
- package/build/src/agents/poppy/session_jwt.d.ts +0 -30
- package/build/src/agents/poppy/session_jwt.js +0 -133
- package/build/src/agents/poppy/store.d.ts +0 -106
- package/build/src/agents/poppy/store.js +0 -261
- package/build/src/agents/poppy/web_session.d.ts +0 -39
- package/build/src/agents/poppy/web_session.js +0 -122
- package/build/src/host/controllers/poppy_consent_controller.d.ts +0 -19
- package/build/src/host/controllers/poppy_consent_controller.js +0 -199
- package/build/src/host/controllers/poppy_controller.d.ts +0 -33
- package/build/src/host/controllers/poppy_controller.js +0 -219
- package/build/src/host/poppy_session_key.d.ts +0 -9
- package/build/src/host/poppy_session_key.js +0 -9
- package/build/src/host/ui-dist/assets/index-D0EIeyKK.js +0 -67
|
@@ -1,13 +1,9 @@
|
|
|
1
|
+
import { getBootedApp } from '../../services/booted_app.js';
|
|
1
2
|
import { supportsAccountStatus } from '../accounts/account_store.js';
|
|
2
3
|
import { findMcpResource } from '../mcp/mcp_oauth.js';
|
|
3
4
|
import { PersonalAgentVerifier } from './agent_identity.js';
|
|
4
5
|
import { PersonalAgentDelegation } from './delegation_service.js';
|
|
5
6
|
import { DelegationStore } from './delegation_store.js';
|
|
6
|
-
import { PoppyClientRegistry } from './poppy/client_registry.js';
|
|
7
|
-
import { hmacNonceSource } from './poppy/dpop.js';
|
|
8
|
-
import { databaseReplayStore, memoryLimiter, memoryReplayStore, redisReplayStore, } from './poppy/replay.js';
|
|
9
|
-
import { PoppyService, poppyUrls } from './poppy/service.js';
|
|
10
|
-
import { PoppyStore } from './poppy/store.js';
|
|
11
7
|
import { AgentRegistryStore } from './registry_store.js';
|
|
12
8
|
import { keystoreSigner } from './signer.js';
|
|
13
9
|
/**
|
|
@@ -27,9 +23,10 @@ export function personalAgentUrls(oidcIssuer, prefix) {
|
|
|
27
23
|
};
|
|
28
24
|
}
|
|
29
25
|
const runtimes = new WeakMap();
|
|
30
|
-
/**
|
|
31
|
-
function
|
|
26
|
+
/** Access tokens do oidc-provider para resources MCP (ver {@link PersonalAgentAccessTokens}). */
|
|
27
|
+
function accessTokens(service) {
|
|
32
28
|
return {
|
|
29
|
+
mcpEnabled: !!service.config.mcp?.enabled,
|
|
33
30
|
isMcpResource(url) {
|
|
34
31
|
try {
|
|
35
32
|
return findMcpResource(url, service.config.issuer, service.config.mcp) !== null;
|
|
@@ -42,11 +39,6 @@ function mcpBridge(service, poppyApis) {
|
|
|
42
39
|
const provider = service.provider;
|
|
43
40
|
if (!provider?.AccessToken || !provider?.Grant)
|
|
44
41
|
return null;
|
|
45
|
-
// Só faz sentido quando a integração MCP do authkit está ligada, ou o MCP é de `apis`.
|
|
46
|
-
if (!service.config.mcp?.enabled &&
|
|
47
|
-
!poppyApis.some((a) => a.type === 'mcp' && a.url.replace(/\/+$/, '') === input.resource.replace(/\/+$/, ''))) {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
42
|
const remaining = Math.max(60, Math.floor((input.grantExpiresAt.getTime() - Date.now()) / 1000));
|
|
51
43
|
let grant = await provider.Grant.find(input.grantId);
|
|
52
44
|
if (!grant) {
|
|
@@ -63,13 +55,13 @@ function mcpBridge(service, poppyApis) {
|
|
|
63
55
|
clientId: input.clientId,
|
|
64
56
|
grantId: input.grantId,
|
|
65
57
|
scope: input.scope,
|
|
66
|
-
gty:
|
|
58
|
+
gty: input.grantType,
|
|
67
59
|
expiresIn: input.expiresIn,
|
|
68
60
|
});
|
|
69
61
|
at.aud = input.resource;
|
|
70
62
|
return at.save();
|
|
71
63
|
},
|
|
72
|
-
async
|
|
64
|
+
async revokeGrant(grantId) {
|
|
73
65
|
const provider = service.provider;
|
|
74
66
|
if (!provider?.Grant)
|
|
75
67
|
return;
|
|
@@ -80,18 +72,32 @@ function mcpBridge(service, poppyApis) {
|
|
|
80
72
|
},
|
|
81
73
|
};
|
|
82
74
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
75
|
+
/** O ledger de grants visto por uma extension: só os ids com o prefixo dela. */
|
|
76
|
+
function scopedLedger(store, prefix) {
|
|
77
|
+
const mine = (id) => typeof id === 'string' && id.startsWith(prefix);
|
|
78
|
+
return {
|
|
79
|
+
async insertGrant(grant) {
|
|
80
|
+
if (!mine(grant.id)) {
|
|
81
|
+
throw new Error(`authkit: grant "${grant.id}" fora do prefixo "${prefix}" da extension.`);
|
|
82
|
+
}
|
|
83
|
+
await store.insertGrant(grant);
|
|
84
|
+
},
|
|
85
|
+
async findGrant(id) {
|
|
86
|
+
return mine(id) ? store.findGrant(id) : null;
|
|
87
|
+
},
|
|
88
|
+
async listGrants(accountId) {
|
|
89
|
+
return (await store.listGrants(accountId)).filter((g) => mine(g.id));
|
|
90
|
+
},
|
|
91
|
+
async unrevokedGrantIdsOfClient(clientId) {
|
|
92
|
+
return (await store.unrevokedGrantIdsOfClient(clientId)).filter(mine);
|
|
93
|
+
},
|
|
94
|
+
async revokeGrantById(id, at) {
|
|
95
|
+
if (mine(id))
|
|
96
|
+
await store.revokeGrantById(id, at);
|
|
97
|
+
},
|
|
98
|
+
insertRefreshToken: (token) => store.insertRefreshToken(token),
|
|
99
|
+
findRefreshToken: (tokenHash) => store.findRefreshToken(tokenHash),
|
|
100
|
+
};
|
|
95
101
|
}
|
|
96
102
|
/**
|
|
97
103
|
* Config com o registro do console por cima: um agente BLOQUEADO no console é
|
|
@@ -112,21 +118,7 @@ function withRegistry(config, registry) {
|
|
|
112
118
|
}
|
|
113
119
|
return null;
|
|
114
120
|
};
|
|
115
|
-
|
|
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
|
-
};
|
|
121
|
+
return { ...config, resolveAgent };
|
|
130
122
|
}
|
|
131
123
|
/**
|
|
132
124
|
* Monta (uma vez por `OidcService`) o runtime de personal agents. `null` quando
|
|
@@ -149,7 +141,7 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
149
141
|
};
|
|
150
142
|
let conn = null;
|
|
151
143
|
const connection = service.config.schema.connection;
|
|
152
|
-
if (config.delegation || config.
|
|
144
|
+
if (config.delegation || (config.extensions ?? []).length > 0) {
|
|
153
145
|
const db = await makeDb();
|
|
154
146
|
conn = () => (connection ? db.connection(connection) : db.connection());
|
|
155
147
|
}
|
|
@@ -177,39 +169,53 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
177
169
|
isAccountActive,
|
|
178
170
|
});
|
|
179
171
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
172
|
+
const extensions = new Map();
|
|
173
|
+
const now = () => options.now?.() ?? new Date();
|
|
174
|
+
if (store && conn && registry) {
|
|
175
|
+
const db = conn;
|
|
176
|
+
const tokens = accessTokens(service);
|
|
177
|
+
for (const extension of config.extensions ?? []) {
|
|
178
|
+
const runtime = await extension.setup({
|
|
179
|
+
protocol: extension.id,
|
|
180
|
+
issuer: service.config.issuer,
|
|
181
|
+
db: () => db(),
|
|
182
|
+
grants: scopedLedger(store, extension.grantIdPrefix),
|
|
183
|
+
registryStatus: async (identifier) => (await registry.find(extension.id, identifier))?.status ?? null,
|
|
184
|
+
deriveSecret: (label) => service.deriveSecret(label),
|
|
185
|
+
isAccountActive,
|
|
186
|
+
make: async (binding) => {
|
|
187
|
+
if (!options.make) {
|
|
188
|
+
throw new Error(`authkit: o binding "${binding}" exige o container do app.`);
|
|
189
|
+
}
|
|
190
|
+
return options.make(binding);
|
|
191
|
+
},
|
|
192
|
+
accessTokens: tokens,
|
|
193
|
+
now,
|
|
194
|
+
});
|
|
195
|
+
extensions.set(extension.id, { extension, runtime });
|
|
196
|
+
}
|
|
202
197
|
}
|
|
198
|
+
/** A extension dona de um grant, pelo prefixo do id. */
|
|
199
|
+
const ownerOf = (grantId) => {
|
|
200
|
+
for (const entry of extensions.values()) {
|
|
201
|
+
if (grantId.startsWith(entry.extension.grantIdPrefix))
|
|
202
|
+
return entry;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
};
|
|
206
|
+
const isExtensionGrant = (grantId) => (config.extensions ?? []).some((e) => grantId.startsWith(e.grantIdPrefix));
|
|
203
207
|
const revokeAgentGrant = async (accountId, grantId) => {
|
|
204
|
-
if (grantId
|
|
205
|
-
|
|
208
|
+
if (isExtensionGrant(grantId)) {
|
|
209
|
+
const owner = ownerOf(grantId);
|
|
210
|
+
return owner ? owner.runtime.revokeAccountGrant(accountId, grantId) : false;
|
|
206
211
|
}
|
|
207
212
|
return delegation ? delegation.revokeGrant(accountId, grantId) : false;
|
|
208
213
|
};
|
|
209
214
|
const revokeAllAgentGrants = async (accountId) => {
|
|
210
215
|
let count = 0;
|
|
211
|
-
|
|
212
|
-
count += await
|
|
216
|
+
for (const { runtime } of extensions.values()) {
|
|
217
|
+
count += await runtime.revokeAllAccountGrants(accountId);
|
|
218
|
+
}
|
|
213
219
|
if (delegation)
|
|
214
220
|
count += await delegation.revokeAllGrants(accountId);
|
|
215
221
|
return count;
|
|
@@ -222,10 +228,11 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
222
228
|
// Expirado já não vale: revogar não muda nada (e não vira evento de auditoria).
|
|
223
229
|
if (!grant || grant.revokedAt || grant.expiresAt <= now)
|
|
224
230
|
return null;
|
|
225
|
-
if (grantId
|
|
226
|
-
|
|
231
|
+
if (isExtensionGrant(grantId)) {
|
|
232
|
+
const owner = ownerOf(grantId);
|
|
233
|
+
if (!owner)
|
|
227
234
|
return null;
|
|
228
|
-
await
|
|
235
|
+
await owner.runtime.revokeGrant(grantId);
|
|
229
236
|
}
|
|
230
237
|
else {
|
|
231
238
|
await store.revokeGrantById(grantId, now);
|
|
@@ -235,9 +242,11 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
235
242
|
const disconnectAgent = async (protocol, identifier) => {
|
|
236
243
|
if (!store)
|
|
237
244
|
return 0;
|
|
238
|
-
if (protocol
|
|
239
|
-
|
|
240
|
-
|
|
245
|
+
if (protocol !== 'pact') {
|
|
246
|
+
const entry = extensions.get(protocol);
|
|
247
|
+
return entry ? entry.runtime.disconnectAgent(identifier) : 0;
|
|
248
|
+
}
|
|
249
|
+
const ids = (await store.unrevokedGrantIdsOfClient(identifier)).filter((id) => !isExtensionGrant(id));
|
|
241
250
|
const at = options.now?.() ?? new Date();
|
|
242
251
|
for (const id of ids)
|
|
243
252
|
await store.revokeGrantById(id, at);
|
|
@@ -248,7 +257,7 @@ export function buildPersonalAgentsRuntime(service, makeDb, options = {}) {
|
|
|
248
257
|
urls,
|
|
249
258
|
verifier: new PersonalAgentVerifier(effective),
|
|
250
259
|
delegation,
|
|
251
|
-
|
|
260
|
+
extensions,
|
|
252
261
|
revokeAgentGrant,
|
|
253
262
|
revokeAllAgentGrants,
|
|
254
263
|
registry,
|
|
@@ -270,3 +279,29 @@ export async function personalAgentsFor(ctx) {
|
|
|
270
279
|
make: (binding) => ctx.containerResolver.make(binding),
|
|
271
280
|
});
|
|
272
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* The runtime an extension's `setup` built (`personalAgents.extensions`), by protocol id. `null`
|
|
284
|
+
* when the extension is not configured (or there is no database). Takes the request, an explicit
|
|
285
|
+
* source (tests, scripts) or nothing (the booted app).
|
|
286
|
+
*
|
|
287
|
+
* Stable for 1.x — see `extension.ts`.
|
|
288
|
+
*/
|
|
289
|
+
export async function personalAgentExtensionRuntime(id, from) {
|
|
290
|
+
let runtime;
|
|
291
|
+
if (from && 'containerResolver' in from) {
|
|
292
|
+
runtime = await personalAgentsFor(from);
|
|
293
|
+
}
|
|
294
|
+
else if (from) {
|
|
295
|
+
const source = from;
|
|
296
|
+
runtime = await buildPersonalAgentsRuntime(source.service, () => source.db(), {
|
|
297
|
+
make: source.make ? (binding) => source.make(binding) : undefined,
|
|
298
|
+
now: source.now ? () => source.now() : undefined,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const app = getBootedApp();
|
|
303
|
+
const service = (await app.container.make('authkit.server'));
|
|
304
|
+
runtime = await buildPersonalAgentsRuntime(service, () => app.container.make('lucid.db'), { make: (binding) => app.container.make(binding) });
|
|
305
|
+
}
|
|
306
|
+
return runtime?.extensions.get(id)?.runtime ?? null;
|
|
307
|
+
}
|
|
@@ -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', 'agent.grant_revoked_by_admin', 'agent.allowed', 'agent.blocked', 'agent.registry_removed'];
|
|
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', 'client.disabled', 'client.enabled', 'service_tenant.token_issued', '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
|
/**
|
|
@@ -107,6 +107,11 @@ export const AUDIT_EVENT_TYPES = [
|
|
|
107
107
|
'session.revoked',
|
|
108
108
|
'account.signed_out_all',
|
|
109
109
|
'client.secret_regenerated',
|
|
110
|
+
// A client was disabled (no new tokens) or enabled again.
|
|
111
|
+
'client.disabled',
|
|
112
|
+
'client.enabled',
|
|
113
|
+
// A service tenant got a token (`partition`, `resource`, `jti` in the metadata).
|
|
114
|
+
'service_tenant.token_issued',
|
|
110
115
|
'roles_catalog.updated',
|
|
111
116
|
// Personal agents (PACT §5): o usuário aprovou/negou a delegação pedida por um
|
|
112
117
|
// agente, ou revogou um grant no console. `clientId` = issuer do agente.
|
|
@@ -114,7 +119,7 @@ export const AUDIT_EVENT_TYPES = [
|
|
|
114
119
|
'agent.delegation_denied',
|
|
115
120
|
'agent.grant_revoked',
|
|
116
121
|
// Console admin: um admin revogou a conexão de um usuário, ou liberou/bloqueou um
|
|
117
|
-
// agente (PACT pelo `iss
|
|
122
|
+
// agente (PACT pelo `iss`; uma extension pelo identificador dela).
|
|
118
123
|
'agent.grant_revoked_by_admin',
|
|
119
124
|
'agent.allowed',
|
|
120
125
|
'agent.blocked',
|
|
@@ -22,6 +22,7 @@ import type { WhatsappCodeSenderBinding } from './host/whatsapp_code_sender.js';
|
|
|
22
22
|
import { type McpOAuthConfigInput, type ResolvedMcpOAuthConfig } from './mcp/mcp_oauth.js';
|
|
23
23
|
import type { PatStore } from './pat/pat_store.js';
|
|
24
24
|
import { type RedirectUriPolicy, type ResolvedRedirectUriPolicy, type ValidateRegistrationHook } from './provider/registration_policy.js';
|
|
25
|
+
import { type ResolvedServiceTenantsConfig, type ServiceTenantsConfigInput } from './service_tenants/config.js';
|
|
25
26
|
export type { AuthAccount };
|
|
26
27
|
export { adapters };
|
|
27
28
|
export type AuthHostRenderer = (ctx: HttpContext, view: string, props: Record<string, unknown>) => unknown;
|
|
@@ -112,18 +113,6 @@ export interface MailHooks {
|
|
|
112
113
|
email: string;
|
|
113
114
|
sudoUrl: string;
|
|
114
115
|
}) => Promise<void>;
|
|
115
|
-
/**
|
|
116
|
-
* Código do Mediated Sign-In pronto do Poppy (`poppyEmailCodeSignIn`): um personal
|
|
117
|
-
* agent quer entrar na conta e a pessoa precisa dar o código a ele. Sem o hook, o
|
|
118
|
-
* host-kit envia pelo mailer default, com branding e i18n.
|
|
119
|
-
*/
|
|
120
|
-
onPoppySignInCode?: (data: {
|
|
121
|
-
email: string;
|
|
122
|
-
code: string;
|
|
123
|
-
/** Nome do agente (metadata dele, ou o host do `client_id`). */
|
|
124
|
-
agentName: string;
|
|
125
|
-
expiresInMinutes: number;
|
|
126
|
-
}) => Promise<void>;
|
|
127
116
|
/**
|
|
128
117
|
* Disparado num login bem-sucedido a partir de um IP NOVO para a conta (nenhum
|
|
129
118
|
* `login.success` anterior desse IP no audit sink), quando `notifications.newLoginEmail`
|
|
@@ -1125,6 +1114,19 @@ export interface AuthServerConfigInput {
|
|
|
1125
1114
|
* `resources` mapeia resource indicators (RFC 8707) para audiences/scopes/formato/TTL por API.
|
|
1126
1115
|
*/
|
|
1127
1116
|
accessTokens?: AccessTokensConfig;
|
|
1117
|
+
/**
|
|
1118
|
+
* Service tenants: confidential `client_credentials` clients that act as a partition of
|
|
1119
|
+
* a resource server (e.g. a durable control plane). `resources` lists the resource
|
|
1120
|
+
* indicators (RFC 8707) that issue their JWT access tokens (RFC 9068), each with an
|
|
1121
|
+
* optional `expiresIn` (seconds, default 600), `audience` and `scopes`:
|
|
1122
|
+
*
|
|
1123
|
+
* ```ts
|
|
1124
|
+
* serviceTenants: { resources: { 'urn:agora:durable': { expiresIn: 600 } } }
|
|
1125
|
+
* ```
|
|
1126
|
+
*
|
|
1127
|
+
* Turns on the `client_credentials` grant. Default: off.
|
|
1128
|
+
*/
|
|
1129
|
+
serviceTenants?: ServiceTenantsConfigInput;
|
|
1128
1130
|
/**
|
|
1129
1131
|
* Login OAuth de clientes MCP (Claude Code, Claude, ChatGPT, VS Code, Cursor): `true` liga o
|
|
1130
1132
|
* registro dinâmico restrito aos redirects deles, o refresh token desses clientes e os
|
|
@@ -1367,6 +1369,8 @@ export interface ResolvedServerConfig {
|
|
|
1367
1369
|
registration: ResolvedRegistrationConfig;
|
|
1368
1370
|
/** Access Tokens resolvido (RFC 9068; default opaque). */
|
|
1369
1371
|
accessTokens: ResolvedAccessTokensConfig;
|
|
1372
|
+
/** Service tenants (always present; `enabled: false` when no resource is declared). */
|
|
1373
|
+
serviceTenants: ResolvedServiceTenantsConfig;
|
|
1370
1374
|
mcp: ResolvedMcpOAuthConfig;
|
|
1371
1375
|
/** Console admin resolvido (sempre presente; default desligado). */
|
|
1372
1376
|
admin: ResolvedAdminConfig;
|
|
@@ -6,7 +6,7 @@ import { composeAuditSink, resolveEvents } from './events/dispatcher.js';
|
|
|
6
6
|
import { resolveBotProtection, } from './host/bot_protection.js';
|
|
7
7
|
import { resolveBranding } from './host/branding.js';
|
|
8
8
|
import { deriveLockedRouteOptions, deriveLockedSettingKeys, } from './host/config_locks.js';
|
|
9
|
-
import { resolveMessages } from './host/i18n.js';
|
|
9
|
+
import { registerExtensionMessages, resolveMessages, } from './host/i18n.js';
|
|
10
10
|
import { resolveOtpLoginConfig, } from './host/otp_login.js';
|
|
11
11
|
import { edgeRenderer } from './host/renderers/edge_renderer.js';
|
|
12
12
|
import { warnUnsatisfiableSudoConfig } from './host/sudo/satisfiability.js';
|
|
@@ -17,6 +17,7 @@ import { loadEncryptionService } from './keys/keystore_crypto.js';
|
|
|
17
17
|
import { KeystoreManager, resolveKeystoreVault } from './keys/keystore_manager.js';
|
|
18
18
|
import { mcpClientRegistration, resolveMcpOAuth, } from './mcp/mcp_oauth.js';
|
|
19
19
|
import { OPEN_REGISTRATION_REDIRECT_POLICY, resolveRedirectUriPolicy, } from './provider/registration_policy.js';
|
|
20
|
+
import { resolveServiceTenants, } from './service_tenants/config.js';
|
|
20
21
|
export { adapters };
|
|
21
22
|
const RATE_LIMIT_DEFAULTS = {
|
|
22
23
|
login: { points: 10, duration: '1 min' },
|
|
@@ -343,7 +344,7 @@ export function defineConfig(config) {
|
|
|
343
344
|
}
|
|
344
345
|
// Personal agents com delegação assinam tokens e recibos com este keystore,
|
|
345
346
|
// e o PACT só admite ES256/RS256 — falha no boot, não na primeira troca de token.
|
|
346
|
-
const personalAgents = resolvePersonalAgentsConfig(config.personalAgents);
|
|
347
|
+
const personalAgents = resolvePersonalAgentsConfig(config.personalAgents, config.issuer);
|
|
347
348
|
if (personalAgents?.delegation)
|
|
348
349
|
assertDelegationSigningKey(jwks);
|
|
349
350
|
// Debaixo do mountPath, a rota curinga do oidc-provider (`${mount}/*`)
|
|
@@ -355,11 +356,13 @@ export function defineConfig(config) {
|
|
|
355
356
|
(personalAgents.prefix === oidcMount || personalAgents.prefix.startsWith(`${oidcMount}/`))) {
|
|
356
357
|
throw new Error(`authkit: personalAgents.prefix "${personalAgents.prefix}" não pode ficar debaixo do mountPath do OIDC ("${oidcMount}").`);
|
|
357
358
|
}
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
359
|
+
for (const extension of personalAgents?.extensions ?? []) {
|
|
360
|
+
if (extension.messages)
|
|
361
|
+
registerExtensionMessages(extension.messages);
|
|
362
|
+
if (oidcMount &&
|
|
363
|
+
(extension.prefix === oidcMount || extension.prefix.startsWith(`${oidcMount}/`))) {
|
|
364
|
+
throw new Error(`authkit: o prefixo "${extension.prefix}" de personalAgents.extensions "${extension.id}" não pode ficar debaixo do mountPath do OIDC ("${oidcMount}").`);
|
|
365
|
+
}
|
|
363
366
|
}
|
|
364
367
|
// BACKSTOP DE SUDO. Um host cujo `sudo.methods` não tem um único método
|
|
365
368
|
// satisfazível por conta sem senha fica bricado para TODA operação sob
|
|
@@ -473,6 +476,7 @@ export function defineConfig(config) {
|
|
|
473
476
|
: undefined,
|
|
474
477
|
registration: resolveRegistration(config.registration),
|
|
475
478
|
accessTokens: resolveAccessTokens(config.issuer, config.accessTokens),
|
|
479
|
+
serviceTenants: resolveServiceTenants(config.serviceTenants, config.accessTokens?.resources),
|
|
476
480
|
mcp: resolveMcpOAuth(config.mcp),
|
|
477
481
|
admin: resolveAdmin(config.admin),
|
|
478
482
|
adminApi: resolveAdminApi(config.adminApi),
|
|
@@ -41,6 +41,10 @@ function fromJwtPayload(payload) {
|
|
|
41
41
|
if (typeof payload.sub !== 'string' || !payload.sub)
|
|
42
42
|
return null;
|
|
43
43
|
const clientId = payload.client_id;
|
|
44
|
+
// No user behind it: a `client_credentials` token (a service tenant's, for one) has
|
|
45
|
+
// `sub` = the client id (RFC 9068 §2.2) and never authenticates an account.
|
|
46
|
+
if (payload.sub === clientId)
|
|
47
|
+
return null;
|
|
44
48
|
return {
|
|
45
49
|
format: 'jwt',
|
|
46
50
|
sub: payload.sub,
|
|
@@ -209,6 +213,9 @@ export function remoteAccessTokenVerifier(options) {
|
|
|
209
213
|
return null;
|
|
210
214
|
if (typeof body.sub !== 'string' || !body.sub)
|
|
211
215
|
return null;
|
|
216
|
+
// `client_credentials` token: no account behind it.
|
|
217
|
+
if (body.sub === body.client_id)
|
|
218
|
+
return null;
|
|
212
219
|
if (typeof body.exp === 'number' && body.exp <= Math.floor(Date.now() / 1000))
|
|
213
220
|
return null;
|
|
214
221
|
return {
|
|
@@ -611,7 +611,7 @@ export default class AccountApiController {
|
|
|
611
611
|
/** Personal agents com delegação ativa na conta (scopes aprovados). */
|
|
612
612
|
async listAgents(ctx) {
|
|
613
613
|
const runtime = await personalAgentsFor(ctx);
|
|
614
|
-
if (!runtime
|
|
614
|
+
if (!runtime || (!runtime.delegation && runtime.extensions.size === 0)) {
|
|
615
615
|
return ctx.response
|
|
616
616
|
.status(404)
|
|
617
617
|
.send(apiErr('capability_unsupported', 'Personal agent delegation is not enabled.'));
|
|
@@ -620,16 +620,18 @@ export default class AccountApiController {
|
|
|
620
620
|
const pact = runtime.delegation
|
|
621
621
|
? (await runtime.delegation.listGrants(userId)).map((g) => ({ ...g, protocol: 'pact' }))
|
|
622
622
|
: [];
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
623
|
+
const others = [];
|
|
624
|
+
for (const [protocol, { runtime: extension }] of runtime.extensions) {
|
|
625
|
+
for (const g of await extension.listAccountGrants(userId))
|
|
626
|
+
others.push({ ...g, protocol });
|
|
627
|
+
}
|
|
628
|
+
return { agents: [...pact, ...others] };
|
|
627
629
|
}
|
|
628
630
|
// ─── DELETE /account/api/agents/:id ──────────────────────────────────────
|
|
629
631
|
/** Revoga a delegação de um personal agent (efeito imediato nos tokens). */
|
|
630
632
|
async revokeAgent(ctx) {
|
|
631
633
|
const runtime = await personalAgentsFor(ctx);
|
|
632
|
-
if (!runtime
|
|
634
|
+
if (!runtime || (!runtime.delegation && runtime.extensions.size === 0)) {
|
|
633
635
|
return ctx.response
|
|
634
636
|
.status(404)
|
|
635
637
|
.send(apiErr('capability_unsupported', 'Personal agent delegation is not enabled.'));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abrir e fechar a sessão de CONTA do authkit (o cookie do console, `ACCOUNT_SESSION_KEY`) a partir
|
|
3
|
+
* de código de fora do host-kit — o navegador de um personal agent que entrou na conta, por
|
|
4
|
+
* exemplo. Sincroniza `ctx.auth` (`adonisAuth.guard`) como o login do próprio console.
|
|
5
|
+
*
|
|
6
|
+
* Estável no 1.x (usado pelas `personalAgents.extensions`).
|
|
7
|
+
*/
|
|
8
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
9
|
+
/** Liga a sessão de conta a `accountId` e sincroniza `ctx.auth`. Não regenera a sessão. */
|
|
10
|
+
export declare function signInAccountSession(ctx: HttpContext, accountId: string): Promise<void>;
|
|
11
|
+
/** Desliga a sessão de conta e sincroniza `ctx.auth`. Não regenera a sessão. */
|
|
12
|
+
export declare function signOutAccountSession(ctx: HttpContext): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
|
|
2
|
+
import { syncAdonisAuthLogin, syncAdonisAuthLogout } from './adonis_auth_sync.js';
|
|
3
|
+
/** Liga a sessão de conta a `accountId` e sincroniza `ctx.auth`. Não regenera a sessão. */
|
|
4
|
+
export async function signInAccountSession(ctx, accountId) {
|
|
5
|
+
// Sem augmentation no barrel (ver `augmentation_isolation.spec.ts`): a sessão é lida sem tipo.
|
|
6
|
+
const session = ctx.session;
|
|
7
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
8
|
+
session.put(ACCOUNT_SESSION_KEY, accountId);
|
|
9
|
+
const account = await service.config.accountStore.findById(accountId);
|
|
10
|
+
if (account)
|
|
11
|
+
await syncAdonisAuthLogin(ctx, service.config, account);
|
|
12
|
+
}
|
|
13
|
+
/** Desliga a sessão de conta e sincroniza `ctx.auth`. Não regenera a sessão. */
|
|
14
|
+
export async function signOutAccountSession(ctx) {
|
|
15
|
+
const session = ctx.session;
|
|
16
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
17
|
+
session.forget(ACCOUNT_SESSION_KEY);
|
|
18
|
+
await syncAdonisAuthLogout(ctx, service.config);
|
|
19
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { HttpContext } from '@adonisjs/core/http';
|
|
|
6
6
|
* em create/regenerate. Audita create/update/delete.
|
|
7
7
|
*/
|
|
8
8
|
export default class ApiClientsController {
|
|
9
|
+
#private;
|
|
9
10
|
index(ctx: HttpContext): Promise<{
|
|
10
11
|
data: {
|
|
11
12
|
clientId: string;
|
|
@@ -19,6 +20,8 @@ export default class ApiClientsController {
|
|
|
19
20
|
backchannelLogoutSessionRequired: boolean;
|
|
20
21
|
clientName: string | null;
|
|
21
22
|
logoUri: string | null;
|
|
23
|
+
disabled: boolean;
|
|
24
|
+
serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
|
|
22
25
|
}[];
|
|
23
26
|
canList: boolean;
|
|
24
27
|
}>;
|
|
@@ -34,6 +37,8 @@ export default class ApiClientsController {
|
|
|
34
37
|
backchannelLogoutSessionRequired: boolean;
|
|
35
38
|
clientName: string | null;
|
|
36
39
|
logoUri: string | null;
|
|
40
|
+
disabled: boolean;
|
|
41
|
+
serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
|
|
37
42
|
}>;
|
|
38
43
|
store(ctx: HttpContext): Promise<void | {
|
|
39
44
|
clientId: string;
|
|
@@ -51,11 +56,43 @@ export default class ApiClientsController {
|
|
|
51
56
|
backchannelLogoutSessionRequired: boolean;
|
|
52
57
|
clientName: string | null;
|
|
53
58
|
logoUri: string | null;
|
|
59
|
+
disabled: boolean;
|
|
60
|
+
serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
|
|
54
61
|
}>;
|
|
55
62
|
regenerateSecret(ctx: HttpContext): Promise<void | {
|
|
56
63
|
clientId: any;
|
|
57
64
|
clientSecret: string;
|
|
58
65
|
}>;
|
|
66
|
+
disable(ctx: HttpContext): Promise<void | {
|
|
67
|
+
clientId: string;
|
|
68
|
+
confidential: boolean;
|
|
69
|
+
grantTypes: string[];
|
|
70
|
+
redirectUris: string[];
|
|
71
|
+
postLogoutRedirectUris: string[];
|
|
72
|
+
tokenEndpointAuthMethod: string;
|
|
73
|
+
applicationType: import("../client_metadata.js").ApplicationType;
|
|
74
|
+
backchannelLogoutUri: string | null;
|
|
75
|
+
backchannelLogoutSessionRequired: boolean;
|
|
76
|
+
clientName: string | null;
|
|
77
|
+
logoUri: string | null;
|
|
78
|
+
disabled: boolean;
|
|
79
|
+
serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
|
|
80
|
+
}>;
|
|
81
|
+
enable(ctx: HttpContext): Promise<void | {
|
|
82
|
+
clientId: string;
|
|
83
|
+
confidential: boolean;
|
|
84
|
+
grantTypes: string[];
|
|
85
|
+
redirectUris: string[];
|
|
86
|
+
postLogoutRedirectUris: string[];
|
|
87
|
+
tokenEndpointAuthMethod: string;
|
|
88
|
+
applicationType: import("../client_metadata.js").ApplicationType;
|
|
89
|
+
backchannelLogoutUri: string | null;
|
|
90
|
+
backchannelLogoutSessionRequired: boolean;
|
|
91
|
+
clientName: string | null;
|
|
92
|
+
logoUri: string | null;
|
|
93
|
+
disabled: boolean;
|
|
94
|
+
serviceTenant: import("../../service_tenants/client_metadata.ts").ServiceTenantInfo | null;
|
|
95
|
+
}>;
|
|
59
96
|
destroy(ctx: HttpContext): Promise<{
|
|
60
97
|
clientId: any;
|
|
61
98
|
deleted: boolean;
|
|
@@ -85,11 +85,16 @@ export default class ApiClientsController {
|
|
|
85
85
|
const id = ctx.request.param('id');
|
|
86
86
|
try {
|
|
87
87
|
const secret = await svc.regenerateSecret(id);
|
|
88
|
+
const tenant = (await svc.find(id))?.serviceTenant;
|
|
88
89
|
await service.config.audit?.record({
|
|
89
90
|
type: 'client.updated',
|
|
90
91
|
clientId: id,
|
|
91
92
|
ip: ctx.request.ip?.() ?? null,
|
|
92
|
-
metadata: {
|
|
93
|
+
metadata: {
|
|
94
|
+
actor: 'admin-api',
|
|
95
|
+
action: 'regenerate_secret',
|
|
96
|
+
...(tenant ? { partition: tenant.partition } : {}),
|
|
97
|
+
},
|
|
93
98
|
});
|
|
94
99
|
return { clientId: id, clientSecret: secret };
|
|
95
100
|
}
|
|
@@ -97,6 +102,31 @@ export default class ApiClientsController {
|
|
|
97
102
|
return ctx.response.conflict(apiError('cannot_regenerate', e.message));
|
|
98
103
|
}
|
|
99
104
|
}
|
|
105
|
+
async disable(ctx) {
|
|
106
|
+
return this.#setDisabled(ctx, true);
|
|
107
|
+
}
|
|
108
|
+
async enable(ctx) {
|
|
109
|
+
return this.#setDisabled(ctx, false);
|
|
110
|
+
}
|
|
111
|
+
/** Disabled clients get `invalid_client` from the next request on. */
|
|
112
|
+
async #setDisabled(ctx, disabled) {
|
|
113
|
+
const { service, svc } = await clientsService(ctx);
|
|
114
|
+
const id = ctx.request.param('id');
|
|
115
|
+
if (!(await svc.setDisabled(id, disabled))) {
|
|
116
|
+
return ctx.response.notFound(apiError('not_found', 'Client não encontrado.'));
|
|
117
|
+
}
|
|
118
|
+
const client = await svc.find(id);
|
|
119
|
+
await service.config.audit?.record({
|
|
120
|
+
type: disabled ? 'client.disabled' : 'client.enabled',
|
|
121
|
+
clientId: id,
|
|
122
|
+
ip: ctx.request.ip?.() ?? null,
|
|
123
|
+
metadata: {
|
|
124
|
+
actor: 'admin-api',
|
|
125
|
+
...(client?.serviceTenant ? { partition: client.serviceTenant.partition } : {}),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return clientDto(client);
|
|
129
|
+
}
|
|
100
130
|
async destroy(ctx) {
|
|
101
131
|
const { service, svc } = await clientsService(ctx);
|
|
102
132
|
const id = ctx.request.param('id');
|