@adonis-agora/authkit-server 1.0.0-rc.7 → 1.0.0-rc.9

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/build/host/views/account/apps.edge +1 -5
  3. package/build/host/views/account/extension.edge +77 -0
  4. package/build/host/views/account/mfa.edge +2 -0
  5. package/build/host/views/account/partials/extension_actions.edge +14 -0
  6. package/build/host/views/account/partials/extension_block.edge +197 -0
  7. package/build/host/views/account/partials/extension_value.edge +8 -0
  8. package/build/host/views/account/partials/nav.edge +14 -0
  9. package/build/host/views/account/security.edge +1 -5
  10. package/build/host/views/account/tokens.edge +2 -2
  11. package/build/host/views/partials/styles.edge +1 -1
  12. package/build/index.d.ts +2 -0
  13. package/build/index.js +1 -0
  14. package/build/providers/authkit_server_provider.js +5 -0
  15. package/build/src/account/extension.d.ts +323 -0
  16. package/build/src/account/extension.js +36 -0
  17. package/build/src/account/http.d.ts +71 -0
  18. package/build/src/account/http.js +369 -0
  19. package/build/src/account/mount.d.ts +35 -0
  20. package/build/src/account/mount.js +99 -0
  21. package/build/src/account/registry.d.ts +48 -0
  22. package/build/src/account/registry.js +229 -0
  23. package/build/src/account/runtime.d.ts +277 -0
  24. package/build/src/account/runtime.js +623 -0
  25. package/build/src/audit/audit_sink.d.ts +1 -1
  26. package/build/src/audit/audit_sink.js +4 -0
  27. package/build/src/console_switcher.d.ts +132 -0
  28. package/build/src/console_switcher.js +206 -0
  29. package/build/src/define_config.d.ts +18 -0
  30. package/build/src/define_config.js +4 -0
  31. package/build/src/host/admin_api/dto.d.ts +1 -1
  32. package/build/src/host/admin_console/admin_console_entry.d.ts +14 -0
  33. package/build/src/host/admin_console/admin_console_entry.js +80 -0
  34. package/build/src/host/admin_console/admin_shell_controller.js +8 -0
  35. package/build/src/host/admin_console/console_switcher_controller.d.ts +11 -0
  36. package/build/src/host/admin_console/console_switcher_controller.js +23 -0
  37. package/build/src/host/auth_host_config.d.ts +3 -0
  38. package/build/src/host/i18n.d.ts +36 -0
  39. package/build/src/host/i18n.js +38 -0
  40. package/build/src/host/rate_limit.d.ts +8 -0
  41. package/build/src/host/rate_limit.js +9 -0
  42. package/build/src/host/register_auth_host.d.ts +14 -0
  43. package/build/src/host/register_auth_host.js +140 -29
  44. package/build/src/host/renderers/edge_renderer.js +5 -0
  45. package/build/src/host/renderers/inertia_renderer.js +7 -1
  46. package/build/src/host/screen_props.d.ts +28 -0
  47. package/build/src/host/ui-dist/assets/index-Dq3dJVBv.js +67 -0
  48. package/build/src/host/ui-dist/assets/{index-DTSmD4RU.css → index-WNIyCEPA.css} +1 -1
  49. package/build/src/host/ui-dist/index.html +2 -2
  50. package/build/types.d.ts +3 -1
  51. package/package.json +3 -2
  52. package/build/src/host/ui-dist/assets/index-u0bMxkLg.js +0 -67
@@ -0,0 +1,623 @@
1
+ import { joinAccountPath } from '../host/account_paths.js';
2
+ import { resolveAccountRoles } from '../host/account_roles.js';
3
+ import { ACCOUNT_SESSION_KEY } from '../host/account_session_key.js';
4
+ import { readActiveOrgFromHostCtx, verifyActiveOrgMembership } from '../host/active_org_cookie.js';
5
+ import { impersonationState } from '../host/impersonation_session.js';
6
+ // ── process state (set when the console routes are mounted) ──────────────────────────────────────
7
+ /** Built-in screens in the navigation, with their fixed order. */
8
+ export const BUILTIN_NAV = [
9
+ { id: 'security', order: 10, label: 'account.security.title' },
10
+ { id: 'mfa', order: 20, label: 'account.mfa.title' },
11
+ { id: 'tokens', order: 30, label: 'account.tokens.title' },
12
+ { id: 'apps', order: 40, label: 'account.apps.title' },
13
+ { id: 'orgs', order: 50, label: 'account.orgs.title' },
14
+ ];
15
+ let state = { pages: [], builtins: {}, registry: null };
16
+ /** Called by `registerAuthHost` with the mounted screens and the frozen pages. */
17
+ export function setAccountConsoleState(next) {
18
+ state = { ...state, ...next };
19
+ }
20
+ /** The served extension pages. */
21
+ export function accountExtensionPages() {
22
+ return state.pages;
23
+ }
24
+ /** Resets the process state (tests). */
25
+ export function resetAccountConsoleState() {
26
+ state = { pages: [], builtins: {}, registry: null };
27
+ }
28
+ export function findPageById(pageId) {
29
+ return state.pages.find((p) => p.page.id === pageId);
30
+ }
31
+ export function findPageBySegment(segment) {
32
+ return state.pages.find((p) => p.segment === segment);
33
+ }
34
+ /** Same-origin path of an extension page. */
35
+ export function pageHref(page, query) {
36
+ const base = joinAccountPath(page.segment);
37
+ const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v !== '')) : null;
38
+ const s = qs?.toString();
39
+ return s ? `${base}?${s}` : base;
40
+ }
41
+ // ── text ──────────────────────────────────────────────────────────────────────────────────────────
42
+ /** Looks up a message: host catalog (overrides win), then the extension's own catalogs. */
43
+ export function makeTranslator(hostMessages, locale, catalogs) {
44
+ const lookup = (key) => {
45
+ if (typeof hostMessages[key] === 'string')
46
+ return hostMessages[key];
47
+ for (const catalog of catalogs) {
48
+ const hit = catalog?.[locale]?.[key] ?? catalog?.[locale.split('-')[0] ?? '']?.[key];
49
+ if (typeof hit === 'string')
50
+ return hit;
51
+ }
52
+ for (const catalog of catalogs) {
53
+ const hit = catalog?.en?.[key];
54
+ if (typeof hit === 'string')
55
+ return hit;
56
+ }
57
+ return key;
58
+ };
59
+ return (text) => {
60
+ if (text === undefined || text === null)
61
+ return '';
62
+ if (typeof text === 'string')
63
+ return text;
64
+ if (typeof text !== 'object' || typeof text.key !== 'string')
65
+ return '';
66
+ const template = lookup(text.key);
67
+ const params = text.params;
68
+ if (!params)
69
+ return template;
70
+ return template.replace(/\{(\w+)\}/g, (match, name) => params[name] === undefined ? match : String(params[name]));
71
+ };
72
+ }
73
+ function fractionDigits(currency) {
74
+ try {
75
+ return (new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions()
76
+ .maximumFractionDigits ?? 2);
77
+ }
78
+ catch {
79
+ return 2;
80
+ }
81
+ }
82
+ function toDate(at) {
83
+ const d = at instanceof Date ? at : new Date(at);
84
+ return Number.isNaN(d.getTime()) ? null : d;
85
+ }
86
+ function relative(date, now, locale) {
87
+ const seconds = Math.round((date.getTime() - now.getTime()) / 1000);
88
+ const units = [
89
+ ['year', 31_536_000],
90
+ ['month', 2_592_000],
91
+ ['day', 86_400],
92
+ ['hour', 3600],
93
+ ['minute', 60],
94
+ ];
95
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
96
+ for (const [unit, size] of units) {
97
+ if (Math.abs(seconds) >= size)
98
+ return rtf.format(Math.round(seconds / size), unit);
99
+ }
100
+ return rtf.format(seconds, 'second');
101
+ }
102
+ export function formatValue(value, t, locale, now) {
103
+ try {
104
+ switch (value?.type) {
105
+ case 'text':
106
+ return { type: 'text', text: t(value.text) };
107
+ case 'badge':
108
+ return { type: 'badge', text: t(value.text), tone: value.tone ?? 'neutral' };
109
+ case 'money': {
110
+ const digits = fractionDigits(value.currency);
111
+ const major = value.amount / 10 ** digits;
112
+ return {
113
+ type: 'money',
114
+ text: new Intl.NumberFormat(locale, {
115
+ style: 'currency',
116
+ currency: value.currency,
117
+ }).format(major),
118
+ amount: value.amount,
119
+ currency: value.currency,
120
+ };
121
+ }
122
+ case 'number': {
123
+ const n = new Intl.NumberFormat(locale).format(value.value);
124
+ const unit = value.unit ? t(value.unit) : '';
125
+ return { type: 'number', text: unit ? `${n} ${unit}` : n, value: value.value };
126
+ }
127
+ case 'date': {
128
+ const date = toDate(value.at);
129
+ if (!date)
130
+ return { type: 'date', text: '' };
131
+ const style = value.style ?? 'date';
132
+ const text = style === 'relative'
133
+ ? relative(date, now, locale)
134
+ : new Intl.DateTimeFormat(locale, {
135
+ dateStyle: 'medium',
136
+ ...(style === 'datetime' ? { timeStyle: 'short' } : {}),
137
+ }).format(date);
138
+ return { type: 'date', text, iso: date.toISOString() };
139
+ }
140
+ default:
141
+ return { type: 'text', text: '' };
142
+ }
143
+ }
144
+ catch {
145
+ return { type: 'text', text: '' };
146
+ }
147
+ }
148
+ // ── hrefs ─────────────────────────────────────────────────────────────────────────────────────────
149
+ /**
150
+ * A safe href: an https URL when `external`, else a same-origin path (`/…`, never `//…` nor
151
+ * `/\…`). Anything else (`javascript:`, `data:`, protocol-relative) is `null`.
152
+ */
153
+ export function safeHref(href, external) {
154
+ if (typeof href !== 'string' || href === '')
155
+ return null;
156
+ if (external) {
157
+ try {
158
+ const url = new URL(href);
159
+ return url.protocol === 'https:' ? url.toString() : null;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ if (!href.startsWith('/') || href.startsWith('//') || href.startsWith('/\\'))
166
+ return null;
167
+ // Whitespace or control characters (code point < 0x20) never belong in a path.
168
+ if ([...href].some((ch) => ch.charCodeAt(0) < 0x20 || /\s/.test(ch)))
169
+ return null;
170
+ return href;
171
+ }
172
+ /** A script `src` a component block may load: same-origin, or https on an allowed origin. */
173
+ export function safeScriptSrc(src, origins) {
174
+ const local = safeHref(src, false);
175
+ if (local)
176
+ return local;
177
+ if (typeof src !== 'string')
178
+ return null;
179
+ try {
180
+ const url = new URL(src);
181
+ return url.protocol === 'https:' && origins.includes(url.origin) ? url.toString() : null;
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ }
187
+ /** JSON for a `<script type="application/json">`: never closes the tag, never runs. */
188
+ export function jsonForDataBlock(value) {
189
+ return JSON.stringify(value ?? {})
190
+ .replace(/</g, '\\u003c')
191
+ .replace(/>/g, '\\u003e')
192
+ .replace(/&/g, '\\u0026')
193
+ .replace(/\u2028/g, '\\u2028')
194
+ .replace(/\u2029/g, '\\u2029');
195
+ }
196
+ function resolveActions(input, refs) {
197
+ const out = [];
198
+ for (const ref of refs ?? []) {
199
+ const r = resolveAction(input.page, ref, input.t);
200
+ if (r)
201
+ out.push(r);
202
+ }
203
+ return out;
204
+ }
205
+ /** The button for `ref`, or `null` when the page has no such action. */
206
+ export function resolveAction(page, ref, t) {
207
+ const action = page.page.actions?.[ref?.action];
208
+ if (!action || !Object.hasOwn(page.page.actions ?? {}, ref.action))
209
+ return null;
210
+ const target = typeof ref.target === 'string' ? ref.target : null;
211
+ const step = action.confirm !== undefined || (action.fields?.length ?? 0) > 0;
212
+ const base = `${joinAccountPath(page.segment)}/actions/${encodeURIComponent(ref.action)}`;
213
+ const qs = target !== null ? `?target=${encodeURIComponent(target)}` : '';
214
+ return {
215
+ action: ref.action,
216
+ target,
217
+ label: t(action.label),
218
+ tone: action.tone === 'danger' ? 'danger' : 'default',
219
+ sudo: action.sudo === true,
220
+ step,
221
+ href: step ? `${base}/confirm${qs}` : base,
222
+ postHref: base,
223
+ apiHref: `${joinAccountPath('api')}/x/${encodeURIComponent(page.page.id)}/actions/${encodeURIComponent(ref.action)}`,
224
+ };
225
+ }
226
+ function nextLink(input, cursor) {
227
+ if (typeof cursor !== 'string' || cursor === '')
228
+ return null;
229
+ return pageHref(input.page, {
230
+ cursor,
231
+ ...(input.query.tab ? { tab: input.query.tab } : {}),
232
+ });
233
+ }
234
+ let mountCounter = 0;
235
+ /** Resolves one block; an unknown or malformed block becomes a warning notice. */
236
+ export function resolveBlock(input, block) {
237
+ const { t, locale, now } = input;
238
+ const value = (v) => formatValue(v, t, locale, now);
239
+ const fallback = (why) => ({ kind: 'notice', tone: 'warning', text: why });
240
+ try {
241
+ switch (block?.kind) {
242
+ case 'notice':
243
+ return {
244
+ kind: 'notice',
245
+ tone: ['info', 'success', 'warning', 'danger'].includes(block.tone) ? block.tone : 'info',
246
+ text: t(block.text),
247
+ };
248
+ case 'keyValue':
249
+ return {
250
+ kind: 'keyValue',
251
+ title: block.title ? t(block.title) : null,
252
+ rows: (block.rows ?? []).map((row) => ({ label: t(row.label), value: value(row.value) })),
253
+ actions: resolveActions(input, block.actions),
254
+ };
255
+ case 'list':
256
+ return {
257
+ kind: 'list',
258
+ title: block.title ? t(block.title) : null,
259
+ empty: t(block.empty),
260
+ items: (block.items ?? []).map((item) => ({
261
+ title: t(item.title),
262
+ subtitle: item.subtitle ? t(item.subtitle) : null,
263
+ meta: (item.meta ?? []).map(value),
264
+ badge: item.badge ? value(item.badge) : null,
265
+ href: safeHref(item.href, item.external),
266
+ external: item.external === true,
267
+ actions: resolveActions(input, item.actions),
268
+ })),
269
+ nextHref: nextLink(input, block.nextCursor),
270
+ nextCursor: block.nextCursor ?? null,
271
+ };
272
+ case 'table': {
273
+ const columns = (block.columns ?? []).map((c) => ({ id: c.id, label: t(c.label) }));
274
+ const rows = (block.rows ?? []).map((row) => ({
275
+ cells: columns.map((c) => row.cells?.[c.id]
276
+ ? value(row.cells[c.id])
277
+ : { type: 'text', text: '' }),
278
+ actions: resolveActions(input, row.actions),
279
+ }));
280
+ return {
281
+ kind: 'table',
282
+ title: block.title ? t(block.title) : null,
283
+ empty: t(block.empty),
284
+ columns,
285
+ rows,
286
+ hasActions: rows.some((r) => r.actions.length > 0),
287
+ nextHref: nextLink(input, block.nextCursor),
288
+ nextCursor: block.nextCursor ?? null,
289
+ };
290
+ }
291
+ case 'meter': {
292
+ const used = Number(block.used) || 0;
293
+ const limit = typeof block.limit === 'number' ? block.limit : null;
294
+ const unit = block.unit ? ` ${t(block.unit)}` : '';
295
+ const fmt = new Intl.NumberFormat(locale);
296
+ return {
297
+ kind: 'meter',
298
+ label: t(block.label),
299
+ used,
300
+ limit,
301
+ percent: limit !== null && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : null,
302
+ text: limit !== null
303
+ ? `${fmt.format(used)} / ${fmt.format(limit)}${unit}`
304
+ : `${fmt.format(used)}${unit}`,
305
+ resetsAt: block.resetsAt
306
+ ? value({ type: 'date', at: block.resetsAt, style: 'relative' })
307
+ : null,
308
+ };
309
+ }
310
+ case 'actions':
311
+ return { kind: 'actions', items: resolveActions(input, block.items) };
312
+ case 'link': {
313
+ const href = safeHref(block.href, block.external);
314
+ if (!href)
315
+ return fallback(t({ key: 'account.extension.unsafe_link' }));
316
+ return { kind: 'link', label: t(block.label), href, external: block.external === true };
317
+ }
318
+ case 'toolPreview':
319
+ return {
320
+ kind: 'toolPreview',
321
+ title: t(block.title),
322
+ tool: typeof block.tool === 'string' ? block.tool : null,
323
+ subtitle: block.subtitle ? t(block.subtitle) : null,
324
+ args: (block.args ?? []).map((a) => ({ label: t(a.label), value: value(a.value) })),
325
+ body: block.body && typeof block.body.content === 'string'
326
+ ? {
327
+ format: block.body.format === 'json' ? 'json' : 'text',
328
+ content: block.body.content,
329
+ }
330
+ : null,
331
+ actions: resolveActions(input, block.actions),
332
+ };
333
+ case 'component': {
334
+ const provider = String(block.provider ?? '');
335
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(provider)) {
336
+ return fallback(t({ key: 'account.extension.unknown_block' }));
337
+ }
338
+ const scripts = [];
339
+ for (const script of (block.scripts ?? [])) {
340
+ const src = safeScriptSrc(script?.src, input.page.scriptOrigins);
341
+ if (!src)
342
+ return fallback(t({ key: 'account.extension.unsafe_script' }));
343
+ scripts.push({
344
+ src,
345
+ integrity: typeof script.integrity === 'string' ? script.integrity : null,
346
+ module: script.module === true,
347
+ });
348
+ }
349
+ const fb = block.fallback ? safeHref(block.fallback.href, block.fallback.external) : null;
350
+ mountCounter = (mountCounter + 1) % 1_000_000;
351
+ const props = (block.props ?? {});
352
+ return {
353
+ kind: 'component',
354
+ provider,
355
+ mountId: `authkit-component-${input.page.page.id.replace(/\./g, '-')}-${mountCounter}`,
356
+ scripts,
357
+ propsJson: jsonForDataBlock(props),
358
+ props,
359
+ submit: block.submit ? resolveAction(input.page, block.submit, t) : null,
360
+ fallback: fb && block.fallback
361
+ ? {
362
+ label: t(block.fallback.label),
363
+ href: fb,
364
+ external: block.fallback.external === true,
365
+ }
366
+ : null,
367
+ };
368
+ }
369
+ default:
370
+ return fallback(t({ key: 'account.extension.unknown_block' }));
371
+ }
372
+ }
373
+ catch {
374
+ return fallback(t({ key: 'account.extension.unknown_block' }));
375
+ }
376
+ }
377
+ export function resolvePageView(input) {
378
+ const tabs = (input.view.tabs ?? []).map((tab, index) => ({
379
+ id: tab.id,
380
+ label: input.t(tab.label),
381
+ href: pageHref(input.page, { tab: tab.id }),
382
+ active: input.query.tab ? input.query.tab === tab.id : index === 0,
383
+ }));
384
+ return {
385
+ id: input.page.page.id,
386
+ extension: input.page.extensionId,
387
+ title: input.t(input.page.page.title),
388
+ href: pageHref(input.page),
389
+ tabs,
390
+ blocks: (Array.isArray(input.view.blocks) ? input.view.blocks : []).map((b) => resolveBlock(input, b)),
391
+ };
392
+ }
393
+ const perRequest = new WeakMap();
394
+ function tenantFrom(activeOrg) {
395
+ if (!activeOrg)
396
+ return null;
397
+ return {
398
+ id: activeOrg.id,
399
+ kind: 'organization',
400
+ source: 'authkit',
401
+ slug: activeOrg.slug,
402
+ role: activeOrg.role,
403
+ };
404
+ }
405
+ async function readActiveOrg(ctx, cfg, accountId) {
406
+ try {
407
+ const claimed = readActiveOrgFromHostCtx(ctx, { appKey: cfg.appKey });
408
+ const verified = await verifyActiveOrgMembership(cfg.accountStore, accountId, claimed);
409
+ return verified ? { id: verified.orgId, slug: verified.orgSlug, role: verified.orgRole } : null;
410
+ }
411
+ catch {
412
+ return null;
413
+ }
414
+ }
415
+ /** The console context of this request, built once. `null` without a signed-in account. */
416
+ export function consoleRequest(ctx) {
417
+ const key = ctx;
418
+ const existing = perRequest.get(key);
419
+ if (existing)
420
+ return existing;
421
+ const built = (async () => {
422
+ const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
423
+ if (!accountId)
424
+ return null;
425
+ const service = await ctx.containerResolver.make('authkit.server');
426
+ const cfg = service.config;
427
+ const account = (await cfg.accountStore.findById(accountId));
428
+ if (!account)
429
+ return null;
430
+ const roles = await resolveAccountRoles(cfg, account);
431
+ const activeOrg = await readActiveOrg(ctx, cfg, accountId);
432
+ const tenant = tenantFrom(activeOrg);
433
+ const config = cfg.accountConsole ?? {};
434
+ const summary = {
435
+ id: String(account.id),
436
+ email: account.email ?? null,
437
+ name: account.name ?? null,
438
+ roles,
439
+ };
440
+ const subject = config.subject
441
+ ? await config.subject(summary, { activeOrg, tenant })
442
+ : { type: 'User', id: summary.id };
443
+ const impersonation = impersonationState(ctx);
444
+ const locale = typeof cfg.locale === 'string' ? cfg.locale : 'en';
445
+ const base = {
446
+ account: summary,
447
+ activeOrg,
448
+ tenant,
449
+ subject,
450
+ impersonatedBy: impersonation.active ? (impersonation.impersonatorId ?? 'unknown') : null,
451
+ locale,
452
+ url(pageId, query) {
453
+ const page = findPageById(pageId);
454
+ return page ? pageHref(page, query) : joinAccountPath('');
455
+ },
456
+ make: (binding) => ctx.containerResolver.make(binding),
457
+ now: () => new Date(),
458
+ };
459
+ return {
460
+ base,
461
+ hostMessages: (cfg.messages ?? {}),
462
+ locale,
463
+ config,
464
+ cfg,
465
+ visible: new Map(),
466
+ };
467
+ })();
468
+ perRequest.set(key, built);
469
+ return built;
470
+ }
471
+ /** Validated query: `cursor` and `tab` only. */
472
+ export function validatedQuery(raw) {
473
+ const out = {};
474
+ const cursor = raw.cursor;
475
+ if (typeof cursor === 'string' && cursor.length > 0 && cursor.length <= 512)
476
+ out.cursor = cursor;
477
+ const tab = raw.tab;
478
+ if (typeof tab === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(tab))
479
+ out.tab = tab;
480
+ return out;
481
+ }
482
+ export function extensionContext(request, query) {
483
+ return { ...request.base, query };
484
+ }
485
+ /** The translator for one page (its extension's catalogs). */
486
+ export function translatorFor(request, page) {
487
+ const registry = state.registry;
488
+ const catalogs = page
489
+ ? [registry?.extensions().find((e) => e.id === page.extensionId)?.messages]
490
+ : (registry?.extensions().map((e) => e.messages) ?? []);
491
+ return makeTranslator(request.hostMessages, request.locale, catalogs);
492
+ }
493
+ /** `page.visible(ctx)`, once per request; a throw hides the page. */
494
+ export function isPageVisible(request, page) {
495
+ const cached = request.visible.get(page.page.id);
496
+ if (cached)
497
+ return cached;
498
+ const decided = (async () => {
499
+ if (typeof page.page.visible !== 'function')
500
+ return true;
501
+ try {
502
+ return (await page.page.visible(extensionContext(request, {}))) !== false;
503
+ }
504
+ catch (error) {
505
+ logExtensionError(page, 'visible', error);
506
+ return false;
507
+ }
508
+ })();
509
+ request.visible.set(page.page.id, decided);
510
+ return decided;
511
+ }
512
+ export function logExtensionError(page, where, error) {
513
+ const message = error instanceof Error ? error.message : String(error);
514
+ console.error(`authkit: account extension "${page.extensionId}" page "${page.page.id}" ${where}() failed: ${message}`);
515
+ }
516
+ /** Runs `fn` with a time budget. */
517
+ export async function withTimeout(work, ms, what) {
518
+ let timer;
519
+ try {
520
+ return await Promise.race([
521
+ work,
522
+ new Promise((_, reject) => {
523
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms);
524
+ }),
525
+ ]);
526
+ }
527
+ finally {
528
+ if (timer !== undefined)
529
+ clearTimeout(timer);
530
+ }
531
+ }
532
+ /** Loads and resolves a page; a throw or timeout becomes a danger notice. */
533
+ export async function loadResolvedPage(request, page, query) {
534
+ const t = translatorFor(request, page);
535
+ let view;
536
+ try {
537
+ const loaded = await withTimeout(page.page.load(extensionContext(request, query)), request.config.loadTimeoutMs ?? 5000, 'load()');
538
+ if (!loaded || typeof loaded !== 'object' || !Array.isArray(loaded.blocks)) {
539
+ throw new Error('load() must return { blocks: [...] }');
540
+ }
541
+ view = loaded;
542
+ }
543
+ catch (error) {
544
+ logExtensionError(page, 'load', error);
545
+ view = {
546
+ blocks: [{ kind: 'notice', tone: 'danger', text: { key: 'account.extension.load_failed' } }],
547
+ };
548
+ }
549
+ return resolvePageView({ page, view, t, locale: request.locale, now: request.base.now(), query });
550
+ }
551
+ const GROUP_ORDER = ['account', 'billing', 'ai', 'content'];
552
+ /** Mounted built-in screens (id → path). */
553
+ export function mountedBuiltins() {
554
+ return state.builtins;
555
+ }
556
+ /**
557
+ * The account console navigation: mounted built-in screens plus the extension pages visible to
558
+ * this account, sorted by `(group, order, id)`.
559
+ */
560
+ export async function buildAccountNav(ctx, activeId) {
561
+ const request = await consoleRequest(ctx).catch(() => null);
562
+ const t = request
563
+ ? translatorFor(request, null)
564
+ : (text) => (typeof text === 'string' ? text : (text?.key ?? ''));
565
+ const host = (key) => (request ? (request.hostMessages[key] ?? key) : key);
566
+ const entries = [];
567
+ for (const builtin of BUILTIN_NAV) {
568
+ const href = state.builtins[builtin.id];
569
+ if (!href)
570
+ continue;
571
+ entries.push({
572
+ id: builtin.id,
573
+ label: host(builtin.label),
574
+ href,
575
+ active: activeId === builtin.id,
576
+ group: 'account',
577
+ order: builtin.order,
578
+ });
579
+ }
580
+ if (request) {
581
+ const visible = await Promise.all(state.pages.map((p) => isPageVisible(request, p)));
582
+ state.pages.forEach((page, index) => {
583
+ if (!visible[index] || page.page.nav === false)
584
+ return;
585
+ const nav = page.page.nav ?? {};
586
+ entries.push({
587
+ id: page.page.id,
588
+ label: t(nav.label ?? page.page.title),
589
+ href: pageHref(page),
590
+ active: activeId === page.page.id,
591
+ group: nav.group ?? 'account',
592
+ order: page.order,
593
+ });
594
+ });
595
+ }
596
+ return entries.sort((a, b) => GROUP_ORDER.indexOf(a.group) - GROUP_ORDER.indexOf(b.group) ||
597
+ a.order - b.order ||
598
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
599
+ }
600
+ const NAV_VIEWS = {
601
+ 'account/security': 'security',
602
+ 'account/mfa': 'mfa',
603
+ 'account/tokens': 'tokens',
604
+ 'account/apps': 'apps',
605
+ 'account/orgs': 'orgs',
606
+ 'account/extension': 'extension',
607
+ };
608
+ /**
609
+ * The `accountNav` prop of an account screen (`null` for other views). Never throws: a broken
610
+ * extension leaves the built-in entries.
611
+ */
612
+ export async function accountNavFor(ctx, view, props) {
613
+ const active = NAV_VIEWS[view];
614
+ if (!active)
615
+ return null;
616
+ const activeId = active === 'extension' ? (props.page?.id ?? null) : active;
617
+ try {
618
+ return await buildAccountNav(ctx, activeId);
619
+ }
620
+ catch {
621
+ return [];
622
+ }
623
+ }
@@ -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', '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'];
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', 'account.extension.redirect', 'account.extension.action'];
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
  /**
@@ -124,4 +124,8 @@ export const AUDIT_EVENT_TYPES = [
124
124
  'agent.allowed',
125
125
  'agent.blocked',
126
126
  'agent.registry_removed',
127
+ // Console de conta: uma action de extension mandou o usuário para fora do app
128
+ // (`redirectTo.external`), ou uma action sensível (sudo/danger) rodou.
129
+ 'account.extension.redirect',
130
+ 'account.extension.action',
127
131
  ];