@adonis-agora/authkit-server 1.0.0-rc.7 → 1.0.0-rc.8
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 +6 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +1 -0
- package/build/src/console_switcher.d.ts +132 -0
- package/build/src/console_switcher.js +206 -0
- package/build/src/host/admin_console/admin_console_entry.d.ts +14 -0
- package/build/src/host/admin_console/admin_console_entry.js +80 -0
- package/build/src/host/admin_console/admin_shell_controller.js +8 -0
- package/build/src/host/admin_console/console_switcher_controller.d.ts +11 -0
- package/build/src/host/admin_console/console_switcher_controller.js +23 -0
- package/build/src/host/register_auth_host.d.ts +8 -0
- package/build/src/host/register_auth_host.js +112 -29
- package/build/src/host/ui-dist/assets/index-DlKYAhpy.js +67 -0
- package/build/src/host/ui-dist/assets/{index-DTSmD4RU.css → index-WNIyCEPA.css} +1 -1
- package/build/src/host/ui-dist/index.html +2 -2
- package/package.json +1 -1
- package/build/src/host/ui-dist/assets/index-u0bMxkLg.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @adonis-agora/authkit-server
|
|
2
2
|
|
|
3
|
+
## 1.0.0-rc.8
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#467](https://github.com/DavideCarvalho/adonis-agora-authkit/pull/467) [`65dea93`](https://github.com/DavideCarvalho/adonis-agora-authkit/commit/65dea936568e24a450f64834cae8d1e5885dde57) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Admin console: switch between the `@adonis-agora` consoles. The admin console registers itself in the shared `agora.consoles` registry (title "Identity"); the sidebar brand becomes a menu of the other consoles the viewer may open, the Overview page shows them as a "Consoles" grid, and Ctrl/Cmd-K searches users and organizations plus every other console the viewer may open. New routes `GET {prefix}/api/consoles` and `GET {prefix}/api/consoles/search?q=`. `registerAgoraConsole` is exported for consoles you mount yourself, and `isAgoraProbe(ctx)` tells an auditing `authorize` that another console is only checking access (the structural `ctx.agoraProbe` marker). Nothing to configure.
|
|
8
|
+
|
|
3
9
|
## 1.0.0-rc.7
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/build/index.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type { AuditEvent, AuditEventType, AuditPage, AuditSink, ListAuditParams,
|
|
|
25
25
|
export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
|
|
26
26
|
export type { ConsoleAuthorize, ConsoleAuthorizeResult, ConsoleAuthorizer, ConsoleAuthorizerClass, } from './src/console_authorize.js';
|
|
27
27
|
export { authorizeByToken } from './src/console_authorize.js';
|
|
28
|
+
export type { AgoraConsoleDecision, AgoraConsoleEntry, AgoraConsoleIcon, AgoraConsoleRegistry, AgoraConsoleSearchHit, } from './src/console_switcher.js';
|
|
29
|
+
export { AGORA_CONSOLES_BINDING, isAgoraProbe, registerAgoraConsole, } from './src/console_switcher.js';
|
|
28
30
|
export type { AdminApiConfigInput, AdminConfigInput, AuthHostRenderer, AuthMethodsConfigInput, AuthServerConfigInput, AuthSocialConfig, DynamicRegistrationConfigInput, LoginConfigInput, OrganizationsConfigInput, PasswordlessConfigInput, RateLimitBucket, RateLimitConfigInput, RegistrationConfigInput, ResolvedAdminApiConfig, ResolvedAdminConfig, ResolvedAuthMethodsConfig, ResolvedDynamicRegistrationConfig, ResolvedLoginConfig, ResolvedNotificationsConfig, ResolvedOrganizationsConfig, ResolvedPasswordlessConfig, ResolvedRateLimitConfig, ResolvedRegistrationConfig, ResolvedServerConfig, ResolvedWebauthnConfig, WebauthnConfigInput, } from './src/define_config.js';
|
|
29
31
|
export { adapters, defineConfig, resolveAdmin, resolveAdminApi, resolveAuthMethodsConfig, resolveDynamicRegistration, resolveLogin, resolveNotifications, resolveOrganizations, resolvePasswordless, resolveRateLimit, resolveRegistration, resolveWebauthn, toSeconds, } from './src/define_config.js';
|
|
30
32
|
export type { EventsConfigInput, ResolvedEventsConfig } from './src/events/dispatcher.js';
|
package/build/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { pact, pactProtocol } from './src/agents/protocol.js';
|
|
|
14
14
|
export { personalAgentExtensionRuntime, } from './src/agents/runtime.js';
|
|
15
15
|
export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
|
|
16
16
|
export { authorizeByToken } from './src/console_authorize.js';
|
|
17
|
+
export { AGORA_CONSOLES_BINDING, isAgoraProbe, registerAgoraConsole, } from './src/console_switcher.js';
|
|
17
18
|
export { adapters, defineConfig, resolveAdmin, resolveAdminApi, resolveAuthMethodsConfig, resolveDynamicRegistration, resolveLogin, resolveNotifications, resolveOrganizations, resolvePasswordless, resolveRateLimit, resolveRegistration, resolveWebauthn, toSeconds, } from './src/define_config.js';
|
|
18
19
|
export { buildWebhookBody, composeAuditSink, resolveEvents, signWebhookBody, } from './src/events/dispatcher.js';
|
|
19
20
|
export { inProcessAccessTokenVerifier, remoteAccessTokenVerifier, } from './src/host/access_token_verifier.js';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/**
|
|
3
|
+
* The console switcher contract, version 1.
|
|
4
|
+
*
|
|
5
|
+
* Every `@adonis-agora` console registers itself into ONE data-only registry bound as a value at
|
|
6
|
+
* `'agora.consoles'` (created by whichever lib boots first). Each console then shows a switcher to
|
|
7
|
+
* the other consoles the viewer may open. The types are declared locally in every lib (TypeScript is
|
|
8
|
+
* structural), so no lib imports another.
|
|
9
|
+
*/
|
|
10
|
+
/** The container binding the registry lives at. */
|
|
11
|
+
export declare const AGORA_CONSOLES_BINDING = "agora.consoles";
|
|
12
|
+
/** Bound as a VALUE under `'agora.consoles'`. Data only, so every lib's copy is identical. */
|
|
13
|
+
export interface AgoraConsoleRegistry {
|
|
14
|
+
readonly version: 1;
|
|
15
|
+
readonly entries: AgoraConsoleEntry[];
|
|
16
|
+
}
|
|
17
|
+
export type AgoraConsoleIcon = 'workflow' | 'telescope' | 'media' | 'payments' | 'agent' | 'identity' | 'generic';
|
|
18
|
+
/** The decision a console's gate returns. Never throws. */
|
|
19
|
+
export interface AgoraConsoleDecision {
|
|
20
|
+
allowed: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
redirectTo?: string;
|
|
23
|
+
}
|
|
24
|
+
/** One search hit. `href` is a same-origin absolute path. */
|
|
25
|
+
export interface AgoraConsoleSearchHit {
|
|
26
|
+
title: string;
|
|
27
|
+
subtitle?: string;
|
|
28
|
+
href: string;
|
|
29
|
+
}
|
|
30
|
+
export interface AgoraConsoleEntry {
|
|
31
|
+
/** `/^[a-z][a-z0-9-]*$/`: `'durable'`, `'telescope'`, `'authkit'`, … */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Product title (`'Identity'`). */
|
|
34
|
+
title: string;
|
|
35
|
+
/** Package name, shown as secondary text (`'@adonis-agora/authkit-server'`). */
|
|
36
|
+
package?: string;
|
|
37
|
+
/** Unknown values render as `'generic'`. */
|
|
38
|
+
icon: AgoraConsoleIcon;
|
|
39
|
+
/** Same-origin absolute path of the console shell (`'/admin'`). */
|
|
40
|
+
path: string;
|
|
41
|
+
/** Lower first. durable 10, telescope 20, agent 30, payments 40, media 50, authkit 90. */
|
|
42
|
+
order: number;
|
|
43
|
+
/**
|
|
44
|
+
* The console's resolved gate (the one its own routes run). While a switcher runs it for another
|
|
45
|
+
* console, ``ctx.agoraProbe` is `true` (see `isAgoraProbe`), so an authorizer that writes an audit entry per call can skip.
|
|
46
|
+
*/
|
|
47
|
+
authorize(ctx: HttpContext): Promise<AgoraConsoleDecision>;
|
|
48
|
+
/** Global search provider. Only called after `authorize` allowed the viewer. */
|
|
49
|
+
search?(ctx: HttpContext, query: string, limit: number): Promise<AgoraConsoleSearchHit[]>;
|
|
50
|
+
}
|
|
51
|
+
/** What `GET <console api>/consoles` answers. */
|
|
52
|
+
export interface VisibleConsoles {
|
|
53
|
+
version: 1;
|
|
54
|
+
current: string;
|
|
55
|
+
consoles: Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
package?: string;
|
|
59
|
+
icon: AgoraConsoleIcon;
|
|
60
|
+
href: string;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
/** What `GET <console api>/consoles/search?q=` answers. */
|
|
64
|
+
export interface ConsoleSearchResults {
|
|
65
|
+
version: 1;
|
|
66
|
+
query: string;
|
|
67
|
+
groups: Array<{
|
|
68
|
+
id: string;
|
|
69
|
+
title: string;
|
|
70
|
+
icon: AgoraConsoleIcon;
|
|
71
|
+
hits: AgoraConsoleSearchHit[];
|
|
72
|
+
}>;
|
|
73
|
+
}
|
|
74
|
+
/** Default time each console's `authorize` (and `search`) gets before it counts as hidden. */
|
|
75
|
+
export declare const CONSOLE_PROBE_TIMEOUT_MS = 300;
|
|
76
|
+
/** Hits returned per console by the global search. */
|
|
77
|
+
export declare const CONSOLE_SEARCH_LIMIT = 5;
|
|
78
|
+
interface ContainerLike {
|
|
79
|
+
hasBinding(binding: string): boolean;
|
|
80
|
+
bindValue(binding: string, value: unknown): void;
|
|
81
|
+
make(binding: string): Promise<unknown>;
|
|
82
|
+
}
|
|
83
|
+
interface AppLike {
|
|
84
|
+
container: ContainerLike;
|
|
85
|
+
}
|
|
86
|
+
interface LoggerLike {
|
|
87
|
+
warn(message: string): void;
|
|
88
|
+
debug?(message: string): void;
|
|
89
|
+
}
|
|
90
|
+
/** A same-origin absolute path: starts with `/`, not `//`, no backslash, no control characters. */
|
|
91
|
+
export declare function isSameOriginPath(value: unknown): value is string;
|
|
92
|
+
/**
|
|
93
|
+
* Add a console to the shared `'agora.consoles'` registry, creating it when absent. Skips (never
|
|
94
|
+
* throws) a registry of another version; warns and skips a duplicate id. Throws on an invalid `id`
|
|
95
|
+
* or `path`: that is a programming error. Apps can call it to register a console they mount by hand.
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* await registerAgoraConsole(app, { id: 'reports', title: 'Reports', icon: 'generic', path: '/reports',
|
|
99
|
+
* order: 100, authorize: async (ctx) => ({ allowed: await isStaff(ctx) }) })
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export declare function registerAgoraConsole(app: AppLike, entry: AgoraConsoleEntry, logger?: LoggerLike): Promise<boolean>;
|
|
103
|
+
/** The registry, when one is bound and is version 1. */
|
|
104
|
+
export declare function readAgoraConsoles(app: AppLike): Promise<AgoraConsoleRegistry | null>;
|
|
105
|
+
/** How many consoles besides `selfId` are registered (the shell's `switcher.others`). */
|
|
106
|
+
export declare function countOtherConsoles(registry: AgoraConsoleRegistry | null, selfId: string): number;
|
|
107
|
+
/**
|
|
108
|
+
* Whether a console switcher is running this `authorize` on behalf of another console (it only asks
|
|
109
|
+
* whether to list it). The marker is `ctx.agoraProbe === true`, set structurally by every
|
|
110
|
+
* `@adonis-agora` console; an authorizer that audits every call can skip then.
|
|
111
|
+
*/
|
|
112
|
+
export declare function isAgoraProbe(ctx: unknown): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* The consoles (other than `selfId`) the viewer may open: every `authorize` runs in parallel, each
|
|
115
|
+
* raced against 300 ms; a timeout, a rejection or anything but `allowed: true` hides the console.
|
|
116
|
+
*/
|
|
117
|
+
export declare function listVisibleConsoles(registry: AgoraConsoleRegistry | null, selfId: string, ctx: HttpContext, options?: {
|
|
118
|
+
timeoutMs?: number;
|
|
119
|
+
logger?: LoggerLike;
|
|
120
|
+
}): Promise<VisibleConsoles>;
|
|
121
|
+
/**
|
|
122
|
+
* Global search across the consoles the viewer may open. The current console (`selfId`, already
|
|
123
|
+
* authorized by its own route guard) is searched without re-running its gate; the others only after
|
|
124
|
+
* their `authorize` allowed. Each `search` gets `timeoutMs` and returns at most `limit` hits; a hit
|
|
125
|
+
* whose `href` is not a same-origin path is dropped.
|
|
126
|
+
*/
|
|
127
|
+
export declare function searchConsoles(registry: AgoraConsoleRegistry | null, selfId: string, ctx: HttpContext, query: string, options?: {
|
|
128
|
+
timeoutMs?: number;
|
|
129
|
+
limit?: number;
|
|
130
|
+
logger?: LoggerLike;
|
|
131
|
+
}): Promise<ConsoleSearchResults>;
|
|
132
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console switcher contract, version 1.
|
|
3
|
+
*
|
|
4
|
+
* Every `@adonis-agora` console registers itself into ONE data-only registry bound as a value at
|
|
5
|
+
* `'agora.consoles'` (created by whichever lib boots first). Each console then shows a switcher to
|
|
6
|
+
* the other consoles the viewer may open. The types are declared locally in every lib (TypeScript is
|
|
7
|
+
* structural), so no lib imports another.
|
|
8
|
+
*/
|
|
9
|
+
/** The container binding the registry lives at. */
|
|
10
|
+
export const AGORA_CONSOLES_BINDING = 'agora.consoles';
|
|
11
|
+
const CONSOLE_ID = /^[a-z][a-z0-9-]*$/;
|
|
12
|
+
const ICONS = [
|
|
13
|
+
'workflow',
|
|
14
|
+
'telescope',
|
|
15
|
+
'media',
|
|
16
|
+
'payments',
|
|
17
|
+
'agent',
|
|
18
|
+
'identity',
|
|
19
|
+
'generic',
|
|
20
|
+
];
|
|
21
|
+
/** Default time each console's `authorize` (and `search`) gets before it counts as hidden. */
|
|
22
|
+
export const CONSOLE_PROBE_TIMEOUT_MS = 300;
|
|
23
|
+
/** Hits returned per console by the global search. */
|
|
24
|
+
export const CONSOLE_SEARCH_LIMIT = 5;
|
|
25
|
+
/** A same-origin absolute path: starts with `/`, not `//`, no backslash, no control characters. */
|
|
26
|
+
export function isSameOriginPath(value) {
|
|
27
|
+
return (typeof value === 'string' &&
|
|
28
|
+
value.startsWith('/') &&
|
|
29
|
+
!value.startsWith('//') &&
|
|
30
|
+
!value.includes('\\') &&
|
|
31
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point
|
|
32
|
+
!/[\u0000-\u001f\u007f]/.test(value));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Add a console to the shared `'agora.consoles'` registry, creating it when absent. Skips (never
|
|
36
|
+
* throws) a registry of another version; warns and skips a duplicate id. Throws on an invalid `id`
|
|
37
|
+
* or `path`: that is a programming error. Apps can call it to register a console they mount by hand.
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* await registerAgoraConsole(app, { id: 'reports', title: 'Reports', icon: 'generic', path: '/reports',
|
|
41
|
+
* order: 100, authorize: async (ctx) => ({ allowed: await isStaff(ctx) }) })
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export async function registerAgoraConsole(app, entry, logger) {
|
|
45
|
+
if (typeof entry?.id !== 'string' || !CONSOLE_ID.test(entry.id)) {
|
|
46
|
+
throw new Error(`registerAgoraConsole: console id ${JSON.stringify(entry?.id)} must match /^[a-z][a-z0-9-]*$/`);
|
|
47
|
+
}
|
|
48
|
+
if (!isSameOriginPath(entry.path)) {
|
|
49
|
+
throw new Error(`registerAgoraConsole: console "${entry.id}" path ${JSON.stringify(entry.path)} must be a same-origin absolute path`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof entry.authorize !== 'function') {
|
|
52
|
+
throw new Error(`registerAgoraConsole: console "${entry.id}" needs an authorize(ctx)`);
|
|
53
|
+
}
|
|
54
|
+
const container = app.container;
|
|
55
|
+
if (!container.hasBinding(AGORA_CONSOLES_BINDING)) {
|
|
56
|
+
container.bindValue(AGORA_CONSOLES_BINDING, { version: 1, entries: [] });
|
|
57
|
+
}
|
|
58
|
+
const registry = (await container.make(AGORA_CONSOLES_BINDING));
|
|
59
|
+
if (registry?.version !== 1 || !Array.isArray(registry.entries))
|
|
60
|
+
return false;
|
|
61
|
+
if (registry.entries.some((e) => e.id === entry.id)) {
|
|
62
|
+
logger?.warn(`[@adonis-agora/authkit-server] console id "${entry.id}" already registered`);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
registry.entries.push(entry);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/** The registry, when one is bound and is version 1. */
|
|
69
|
+
export async function readAgoraConsoles(app) {
|
|
70
|
+
if (!app.container.hasBinding(AGORA_CONSOLES_BINDING))
|
|
71
|
+
return null;
|
|
72
|
+
const registry = (await app.container.make(AGORA_CONSOLES_BINDING));
|
|
73
|
+
if (registry?.version !== 1 || !Array.isArray(registry.entries))
|
|
74
|
+
return null;
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** How many consoles besides `selfId` are registered (the shell's `switcher.others`). */
|
|
78
|
+
export function countOtherConsoles(registry, selfId) {
|
|
79
|
+
return registry ? registry.entries.filter((e) => e.id !== selfId).length : 0;
|
|
80
|
+
}
|
|
81
|
+
function sortEntries(entries) {
|
|
82
|
+
return [...entries].sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
83
|
+
}
|
|
84
|
+
function iconOf(icon) {
|
|
85
|
+
return ICONS.includes(icon) ? icon : 'generic';
|
|
86
|
+
}
|
|
87
|
+
const TIMED_OUT = Symbol('timed out');
|
|
88
|
+
/** Resolves to `TIMED_OUT` when `promise` takes longer than `ms`, or `undefined` when it rejects. */
|
|
89
|
+
async function race(promise, ms) {
|
|
90
|
+
let timer;
|
|
91
|
+
try {
|
|
92
|
+
return await Promise.race([
|
|
93
|
+
Promise.resolve().then(promise),
|
|
94
|
+
new Promise((resolve) => {
|
|
95
|
+
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if (timer)
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a console switcher is running this `authorize` on behalf of another console (it only asks
|
|
109
|
+
* whether to list it). The marker is `ctx.agoraProbe === true`, set structurally by every
|
|
110
|
+
* `@adonis-agora` console; an authorizer that audits every call can skip then.
|
|
111
|
+
*/
|
|
112
|
+
export function isAgoraProbe(ctx) {
|
|
113
|
+
return ctx?.agoraProbe === true;
|
|
114
|
+
}
|
|
115
|
+
/** Runs `fn` with `ctx.agoraProbe = true`, restoring the previous value afterwards. */
|
|
116
|
+
async function probing(ctx, fn) {
|
|
117
|
+
const holder = ctx;
|
|
118
|
+
const previous = holder.agoraProbe;
|
|
119
|
+
holder.agoraProbe = true;
|
|
120
|
+
try {
|
|
121
|
+
return await fn();
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
if (previous === undefined)
|
|
125
|
+
delete holder.agoraProbe;
|
|
126
|
+
else
|
|
127
|
+
holder.agoraProbe = previous;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Runs every other console's gate in parallel (each raced against `timeoutMs`); keeps the allowed. */
|
|
131
|
+
async function allowedEntries(entries, ctx, timeoutMs, logger) {
|
|
132
|
+
return probing(ctx, async () => {
|
|
133
|
+
const decisions = await Promise.all(entries.map(async (entry) => {
|
|
134
|
+
const decision = await race(() => entry.authorize(ctx), timeoutMs);
|
|
135
|
+
if (decision === TIMED_OUT || decision === undefined) {
|
|
136
|
+
logger?.debug?.(`[@adonis-agora/authkit-server] console "${entry.id}" hidden: authorize ${decision === TIMED_OUT ? 'timed out' : 'failed'}`);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return decision?.allowed === true;
|
|
140
|
+
}));
|
|
141
|
+
return entries.filter((_, i) => decisions[i]);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The consoles (other than `selfId`) the viewer may open: every `authorize` runs in parallel, each
|
|
146
|
+
* raced against 300 ms; a timeout, a rejection or anything but `allowed: true` hides the console.
|
|
147
|
+
*/
|
|
148
|
+
export async function listVisibleConsoles(registry, selfId, ctx, options = {}) {
|
|
149
|
+
const others = sortEntries((registry?.entries ?? []).filter((e) => e.id !== selfId));
|
|
150
|
+
const allowed = await allowedEntries(others, ctx, options.timeoutMs ?? CONSOLE_PROBE_TIMEOUT_MS, options.logger);
|
|
151
|
+
return {
|
|
152
|
+
version: 1,
|
|
153
|
+
current: selfId,
|
|
154
|
+
consoles: allowed
|
|
155
|
+
.filter((e) => isSameOriginPath(e.path))
|
|
156
|
+
.map((e) => ({
|
|
157
|
+
id: e.id,
|
|
158
|
+
title: e.title,
|
|
159
|
+
...(typeof e.package === 'string' ? { package: e.package } : {}),
|
|
160
|
+
icon: iconOf(e.icon),
|
|
161
|
+
href: e.path,
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Global search across the consoles the viewer may open. The current console (`selfId`, already
|
|
167
|
+
* authorized by its own route guard) is searched without re-running its gate; the others only after
|
|
168
|
+
* their `authorize` allowed. Each `search` gets `timeoutMs` and returns at most `limit` hits; a hit
|
|
169
|
+
* whose `href` is not a same-origin path is dropped.
|
|
170
|
+
*/
|
|
171
|
+
export async function searchConsoles(registry, selfId, ctx, query, options = {}) {
|
|
172
|
+
const q = query.trim().slice(0, 200);
|
|
173
|
+
const result = { version: 1, query: q, groups: [] };
|
|
174
|
+
if (q.length === 0 || !registry)
|
|
175
|
+
return result;
|
|
176
|
+
const timeoutMs = options.timeoutMs ?? CONSOLE_PROBE_TIMEOUT_MS;
|
|
177
|
+
const limit = options.limit ?? CONSOLE_SEARCH_LIMIT;
|
|
178
|
+
const searchable = sortEntries(registry.entries.filter((e) => typeof e.search === 'function'));
|
|
179
|
+
const self = searchable.filter((e) => e.id === selfId);
|
|
180
|
+
const others = await allowedEntries(searchable.filter((e) => e.id !== selfId), ctx, timeoutMs, options.logger);
|
|
181
|
+
const targets = sortEntries([...self, ...others]);
|
|
182
|
+
// Real searches by the viewer: run without the probe marker.
|
|
183
|
+
const hits = await Promise.all(targets.map((entry) => race(() => entry.search(ctx, q, limit), timeoutMs)));
|
|
184
|
+
targets.forEach((entry, i) => {
|
|
185
|
+
const found = hits[i];
|
|
186
|
+
if (!Array.isArray(found))
|
|
187
|
+
return;
|
|
188
|
+
const safe = found
|
|
189
|
+
.filter((h) => h && typeof h.title === 'string' && isSameOriginPath(h.href))
|
|
190
|
+
.slice(0, limit)
|
|
191
|
+
.map((h) => ({
|
|
192
|
+
title: h.title,
|
|
193
|
+
...(typeof h.subtitle === 'string' ? { subtitle: h.subtitle } : {}),
|
|
194
|
+
href: h.href,
|
|
195
|
+
}));
|
|
196
|
+
if (safe.length > 0) {
|
|
197
|
+
result.groups.push({
|
|
198
|
+
id: entry.id,
|
|
199
|
+
title: entry.title,
|
|
200
|
+
icon: iconOf(entry.icon),
|
|
201
|
+
hits: safe,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import { type AgoraConsoleEntry, type AgoraConsoleRegistry, type AgoraConsoleSearchHit } from '../../console_switcher.js';
|
|
3
|
+
/** The admin console's id in the `'agora.consoles'` registry. */
|
|
4
|
+
export declare const ADMIN_CONSOLE_ID = "authkit";
|
|
5
|
+
/** The `'agora.consoles'` registry, or `null` (none bound, or no booted app). */
|
|
6
|
+
export declare function consoleRegistry(): Promise<AgoraConsoleRegistry | null>;
|
|
7
|
+
/**
|
|
8
|
+
* Global search of the admin console: users by email (as the account store's `listAccounts`
|
|
9
|
+
* search matches them), and organizations by name or slug.
|
|
10
|
+
* Each hit opens the matching drawer in the console.
|
|
11
|
+
*/
|
|
12
|
+
export declare function searchAdminConsole(ctx: HttpContext, adminPrefix: string, query: string, limit: number): Promise<AgoraConsoleSearchHit[]>;
|
|
13
|
+
/** The admin console's registry entry, for the shell mounted at `adminPrefix`. */
|
|
14
|
+
export declare function adminConsoleEntry(adminPrefix: string, authorize: AgoraConsoleEntry['authorize']): AgoraConsoleEntry;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { getBootedApp } from '../../../services/booted_app.js';
|
|
2
|
+
import { supportsOrganizations } from '../../accounts/account_store.js';
|
|
3
|
+
import { readAgoraConsoles, } from '../../console_switcher.js';
|
|
4
|
+
/** The admin console's id in the `'agora.consoles'` registry. */
|
|
5
|
+
export const ADMIN_CONSOLE_ID = 'authkit';
|
|
6
|
+
/** The `'agora.consoles'` registry, or `null` (none bound, or no booted app). */
|
|
7
|
+
export async function consoleRegistry() {
|
|
8
|
+
try {
|
|
9
|
+
return await readAgoraConsoles(getBootedApp());
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const ORG_CACHE_MS = 30_000;
|
|
16
|
+
const orgCache = new WeakMap();
|
|
17
|
+
/** The store's organizations, cached for 30 s per store so a search session does one query. */
|
|
18
|
+
function cachedOrgs(store, load) {
|
|
19
|
+
const hit = orgCache.get(store);
|
|
20
|
+
if (hit && Date.now() - hit.at < ORG_CACHE_MS)
|
|
21
|
+
return hit.rows;
|
|
22
|
+
const rows = load();
|
|
23
|
+
orgCache.set(store, { at: Date.now(), rows });
|
|
24
|
+
rows.catch(() => orgCache.delete(store));
|
|
25
|
+
return rows;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Global search of the admin console: users by email (as the account store's `listAccounts`
|
|
29
|
+
* search matches them), and organizations by name or slug.
|
|
30
|
+
* Each hit opens the matching drawer in the console.
|
|
31
|
+
*/
|
|
32
|
+
export async function searchAdminConsole(ctx, adminPrefix, query, limit) {
|
|
33
|
+
const service = await ctx.containerResolver.make('authkit.server');
|
|
34
|
+
const store = service.config.accountStore;
|
|
35
|
+
const hits = [];
|
|
36
|
+
const accounts = await store.listAccounts({ search: query, page: 1, size: limit });
|
|
37
|
+
for (const account of accounts.data.slice(0, limit)) {
|
|
38
|
+
hits.push({
|
|
39
|
+
title: account.email,
|
|
40
|
+
subtitle: account.name ? `User · ${account.name}` : 'User',
|
|
41
|
+
href: `${adminPrefix}?view=users&user=${encodeURIComponent(account.id)}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// Organizations only from a store that lists them in one query, for queries of 2+ characters,
|
|
45
|
+
// from a list cached briefly per process: search runs on every (debounced) keystroke.
|
|
46
|
+
if (hits.length < limit && query.length >= 2 && supportsOrganizations(store)) {
|
|
47
|
+
const listAll = store
|
|
48
|
+
.listAllOrgsWithMemberCount;
|
|
49
|
+
if (typeof listAll === 'function') {
|
|
50
|
+
const needle = query.toLowerCase();
|
|
51
|
+
const orgs = await cachedOrgs(store, () => listAll.call(store));
|
|
52
|
+
for (const org of orgs) {
|
|
53
|
+
if (hits.length >= limit)
|
|
54
|
+
break;
|
|
55
|
+
if (!org.name.toLowerCase().includes(needle) && !org.slug.toLowerCase().includes(needle)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
hits.push({
|
|
59
|
+
title: org.name,
|
|
60
|
+
subtitle: `Organization · ${org.slug}`,
|
|
61
|
+
href: `${adminPrefix}?view=orgs&org=${encodeURIComponent(org.id)}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return hits;
|
|
67
|
+
}
|
|
68
|
+
/** The admin console's registry entry, for the shell mounted at `adminPrefix`. */
|
|
69
|
+
export function adminConsoleEntry(adminPrefix, authorize) {
|
|
70
|
+
return {
|
|
71
|
+
id: ADMIN_CONSOLE_ID,
|
|
72
|
+
title: 'Identity',
|
|
73
|
+
package: '@adonis-agora/authkit-server',
|
|
74
|
+
icon: 'identity',
|
|
75
|
+
path: adminPrefix,
|
|
76
|
+
order: 90,
|
|
77
|
+
authorize,
|
|
78
|
+
search: (ctx, query, limit) => searchAdminConsole(ctx, adminPrefix, query, limit),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { extname } from 'node:path';
|
|
3
|
+
import { countOtherConsoles } from '../../console_switcher.js';
|
|
3
4
|
import { accountPath } from '../account_paths.js';
|
|
4
5
|
import { resolveAccountRoles, rolesManagedExternally } from '../account_roles.js';
|
|
5
6
|
import { getAdminPrefix } from '../admin_prefix.js';
|
|
7
|
+
import { ADMIN_CONSOLE_ID, consoleRegistry } from './admin_console_entry.js';
|
|
6
8
|
// ─── Shell HTML cache ─────────────────────────────────────────────────────────
|
|
7
9
|
/**
|
|
8
10
|
* Points to the Vite-built index.html inside build/host/ui-dist/.
|
|
@@ -141,6 +143,9 @@ export default class AdminShellController {
|
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
const adminBase = getAdminPrefix();
|
|
146
|
+
// Console switcher: only announced when other `@adonis-agora` consoles are registered; the SPA
|
|
147
|
+
// then asks the endpoint which of them this viewer may open.
|
|
148
|
+
const others = countOtherConsoles(await consoleRegistry(), ADMIN_CONSOLE_ID);
|
|
144
149
|
const authkitConfig = {
|
|
145
150
|
adminBase,
|
|
146
151
|
csrfToken: ctx.request.csrfToken ?? null,
|
|
@@ -152,6 +157,9 @@ export default class AdminShellController {
|
|
|
152
157
|
// Confirmação de identidade (sudo) das ações destrutivas.
|
|
153
158
|
confirm: accountPath('confirm'),
|
|
154
159
|
},
|
|
160
|
+
...(others > 0 ? { switcher: { endpoint: `${adminBase}/api/consoles`, others } } : {}),
|
|
161
|
+
// Global search (Ctrl/Cmd-K) across this console and every other one the viewer may open.
|
|
162
|
+
search: { endpoint: `${adminBase}/api/consoles/search` },
|
|
155
163
|
features: {
|
|
156
164
|
// A página de personal agents só aparece com `personalAgents` no config.
|
|
157
165
|
personalAgents: !!cfg.personalAgents,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
/**
|
|
3
|
+
* The console switcher endpoints of the admin console (behind the admin console's own guard).
|
|
4
|
+
*
|
|
5
|
+
* GET {prefix}/api/consoles → the other consoles this viewer may open
|
|
6
|
+
* GET {prefix}/api/consoles/search?q= → hits from every console this viewer may open
|
|
7
|
+
*/
|
|
8
|
+
export default class ConsoleSwitcherController {
|
|
9
|
+
index(ctx: HttpContext): Promise<import("../../console_switcher.js").VisibleConsoles>;
|
|
10
|
+
search(ctx: HttpContext): Promise<import("../../console_switcher.js").ConsoleSearchResults>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { listVisibleConsoles, searchConsoles } from '../../console_switcher.js';
|
|
2
|
+
import { ADMIN_CONSOLE_ID, consoleRegistry } from './admin_console_entry.js';
|
|
3
|
+
/**
|
|
4
|
+
* The console switcher endpoints of the admin console (behind the admin console's own guard).
|
|
5
|
+
*
|
|
6
|
+
* GET {prefix}/api/consoles → the other consoles this viewer may open
|
|
7
|
+
* GET {prefix}/api/consoles/search?q= → hits from every console this viewer may open
|
|
8
|
+
*/
|
|
9
|
+
export default class ConsoleSwitcherController {
|
|
10
|
+
async index(ctx) {
|
|
11
|
+
const body = await listVisibleConsoles(await consoleRegistry(), ADMIN_CONSOLE_ID, ctx, {
|
|
12
|
+
logger: ctx.logger,
|
|
13
|
+
});
|
|
14
|
+
ctx.response.header('Cache-Control', 'private, no-store');
|
|
15
|
+
return body;
|
|
16
|
+
}
|
|
17
|
+
async search(ctx) {
|
|
18
|
+
const q = ctx.request.input('q', '');
|
|
19
|
+
const body = await searchConsoles(await consoleRegistry(), ADMIN_CONSOLE_ID, ctx, typeof q === 'string' ? q : '', { logger: ctx.logger });
|
|
20
|
+
ctx.response.header('Cache-Control', 'private, no-store');
|
|
21
|
+
return body;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -25,6 +25,14 @@ export declare function wantsJson(ctx: any): boolean;
|
|
|
25
25
|
* As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
|
|
26
26
|
*/
|
|
27
27
|
export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promise<any>;
|
|
28
|
+
/**
|
|
29
|
+
* The admin console gate as the console switcher reads it: `{ allowed }`, never a response, never a
|
|
30
|
+
* throw. Used as the `authorize` of the admin console's `'agora.consoles'` entry.
|
|
31
|
+
*/
|
|
32
|
+
export declare function adminConsoleDecision(ctx: any): Promise<{
|
|
33
|
+
allowed: boolean;
|
|
34
|
+
reason?: 'unauthenticated' | 'forbidden';
|
|
35
|
+
}>;
|
|
28
36
|
/**
|
|
29
37
|
* Opções de montagem das rotas do host-kit.
|
|
30
38
|
*
|