@adonis-agora/authkit-server 1.0.0-rc.8 → 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.
- package/CHANGELOG.md +13 -0
- package/build/host/views/account/apps.edge +1 -5
- package/build/host/views/account/extension.edge +77 -0
- package/build/host/views/account/mfa.edge +2 -0
- package/build/host/views/account/partials/extension_actions.edge +14 -0
- package/build/host/views/account/partials/extension_block.edge +197 -0
- package/build/host/views/account/partials/extension_value.edge +8 -0
- package/build/host/views/account/partials/nav.edge +14 -0
- package/build/host/views/account/security.edge +1 -5
- package/build/host/views/account/tokens.edge +2 -2
- package/build/host/views/partials/styles.edge +1 -1
- package/build/providers/authkit_server_provider.js +5 -0
- package/build/src/account/extension.d.ts +323 -0
- package/build/src/account/extension.js +36 -0
- package/build/src/account/http.d.ts +71 -0
- package/build/src/account/http.js +369 -0
- package/build/src/account/mount.d.ts +35 -0
- package/build/src/account/mount.js +99 -0
- package/build/src/account/registry.d.ts +48 -0
- package/build/src/account/registry.js +229 -0
- package/build/src/account/runtime.d.ts +277 -0
- package/build/src/account/runtime.js +623 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +4 -0
- package/build/src/define_config.d.ts +18 -0
- package/build/src/define_config.js +4 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/auth_host_config.d.ts +3 -0
- package/build/src/host/i18n.d.ts +36 -0
- package/build/src/host/i18n.js +38 -0
- package/build/src/host/rate_limit.d.ts +8 -0
- package/build/src/host/rate_limit.js +9 -0
- package/build/src/host/register_auth_host.d.ts +6 -0
- package/build/src/host/register_auth_host.js +28 -0
- package/build/src/host/renderers/edge_renderer.js +5 -0
- package/build/src/host/renderers/inertia_renderer.js +7 -1
- package/build/src/host/screen_props.d.ts +28 -0
- package/build/src/host/ui-dist/assets/{index-DlKYAhpy.js → index-Dq3dJVBv.js} +26 -26
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/types.d.ts +3 -1
- package/package.json +3 -2
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { accountPath } from '../host/account_paths.js';
|
|
2
|
+
import { redirectExact } from '../host/redirect_exact.js';
|
|
3
|
+
import { resolveRuntimeSettings } from '../host/runtime_settings.js';
|
|
4
|
+
import { isSudoSatisfied } from '../host/sudo_mode.js';
|
|
5
|
+
import { accountExtensionPages, buildAccountNav, consoleRequest, extensionContext, findPageById, isPageVisible, loadResolvedPage, logExtensionError, pageHref, resolveAction, safeHref, translatorFor, validatedQuery, withTimeout, } from './runtime.js';
|
|
6
|
+
/** Flash key of an action's outcome. */
|
|
7
|
+
const FLASH_KEY = 'authkit_account_extension';
|
|
8
|
+
function apiError(code, message, extra = {}) {
|
|
9
|
+
return { error: { code, message, ...extra } };
|
|
10
|
+
}
|
|
11
|
+
function notFound(ctx, json) {
|
|
12
|
+
if (json)
|
|
13
|
+
return ctx.response.status(404).send(apiError('not_found', 'Not found.'));
|
|
14
|
+
return ctx.response.status(404).send('Not found');
|
|
15
|
+
}
|
|
16
|
+
async function requestOr404(ctx, page, json) {
|
|
17
|
+
const request = await consoleRequest(ctx);
|
|
18
|
+
if (!request || !page || !(await isPageVisible(request, page))) {
|
|
19
|
+
notFound(ctx, json);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return { request, page };
|
|
23
|
+
}
|
|
24
|
+
function actionOf(page, name) {
|
|
25
|
+
const actions = page.page.actions ?? {};
|
|
26
|
+
return Object.hasOwn(actions, name) ? (actions[name] ?? null) : null;
|
|
27
|
+
}
|
|
28
|
+
function resolveFields(action, t, values = {}) {
|
|
29
|
+
return (action.fields ?? []).map((field) => ({
|
|
30
|
+
name: field.name,
|
|
31
|
+
label: t(field.label),
|
|
32
|
+
kind: field.kind,
|
|
33
|
+
required: field.required === true,
|
|
34
|
+
maxLength: typeof field.maxLength === 'number' ? field.maxLength : null,
|
|
35
|
+
options: (field.options ?? []).map((o) => ({ value: o.value, label: t(o.label) })),
|
|
36
|
+
value: values[field.name] ?? '',
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
function definitionOf(action, t) {
|
|
40
|
+
return {
|
|
41
|
+
label: t(action.label),
|
|
42
|
+
tone: action.tone === 'danger' ? 'danger' : 'default',
|
|
43
|
+
confirm: action.confirm !== undefined ? t(action.confirm) : null,
|
|
44
|
+
sudo: action.sudo === true,
|
|
45
|
+
fields: resolveFields(action, t),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const DEFAULT_MAX_LENGTH = 2000;
|
|
49
|
+
/** Validates the posted fields against the action's declaration. */
|
|
50
|
+
export function validateActionInput(action, raw, t) {
|
|
51
|
+
const target = typeof raw.target === 'string' && raw.target !== '' && raw.target.length <= 512
|
|
52
|
+
? raw.target
|
|
53
|
+
: null;
|
|
54
|
+
const posted = (raw.fields && typeof raw.fields === 'object' ? raw.fields : {});
|
|
55
|
+
const fields = {};
|
|
56
|
+
for (const field of action.fields ?? []) {
|
|
57
|
+
const value = posted[field.name];
|
|
58
|
+
const text = typeof value === 'string' ? value : '';
|
|
59
|
+
const name = t(field.label);
|
|
60
|
+
if (field.required && text.trim() === '') {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: t({ key: 'account.extension.field_required', params: { field: name } }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (text.length > (field.maxLength ?? DEFAULT_MAX_LENGTH)) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: t({ key: 'account.extension.field_too_long', params: { field: name } }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (field.kind === 'select' &&
|
|
73
|
+
text !== '' &&
|
|
74
|
+
!(field.options ?? []).some((o) => o.value === text)) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: t({ key: 'account.extension.field_invalid', params: { field: name } }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
fields[field.name] = text;
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, target, fields };
|
|
83
|
+
}
|
|
84
|
+
/** The page's flash (set by the last action), read once. */
|
|
85
|
+
function takeFlash(ctx) {
|
|
86
|
+
try {
|
|
87
|
+
const flash = ctx.session?.flashMessages?.get(FLASH_KEY);
|
|
88
|
+
if (flash && typeof flash.text === 'string') {
|
|
89
|
+
return { tone: flash.tone === 'danger' ? 'danger' : 'success', text: flash.text };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* no session */
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
function flash(ctx, tone, text) {
|
|
98
|
+
try {
|
|
99
|
+
ctx.session.flash(FLASH_KEY, { tone, text });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* no session */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function escapeAttr(value) {
|
|
106
|
+
return value
|
|
107
|
+
.replace(/&/g, '&')
|
|
108
|
+
.replace(/"/g, '"')
|
|
109
|
+
.replace(/</g, '<')
|
|
110
|
+
.replace(/>/g, '>');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The `<script>` tag of a `component` block's script: the response's CSP nonce when shield set
|
|
114
|
+
* one, `integrity` + `crossorigin` when the block gave one, `type="module"` when asked.
|
|
115
|
+
*/
|
|
116
|
+
export function componentScriptTag(script, nonce) {
|
|
117
|
+
const attrs = [`src="${escapeAttr(script.src)}"`];
|
|
118
|
+
if (script.module)
|
|
119
|
+
attrs.push('type="module"');
|
|
120
|
+
else
|
|
121
|
+
attrs.push('defer');
|
|
122
|
+
if (nonce)
|
|
123
|
+
attrs.push(`nonce="${escapeAttr(nonce)}"`);
|
|
124
|
+
if (script.integrity) {
|
|
125
|
+
attrs.push(`integrity="${escapeAttr(script.integrity)}"`, 'crossorigin="anonymous"');
|
|
126
|
+
}
|
|
127
|
+
return `<script ${attrs.join(' ')}></script>`;
|
|
128
|
+
}
|
|
129
|
+
async function render(ctx, request, props) {
|
|
130
|
+
const render = request.cfg.render;
|
|
131
|
+
const nonce = cspNonceOf(ctx);
|
|
132
|
+
return render(ctx, 'account/extension', {
|
|
133
|
+
csrfToken: ctx.request.csrfToken ?? '',
|
|
134
|
+
cspNonce: nonce,
|
|
135
|
+
htmlLang: request.locale.toLowerCase(),
|
|
136
|
+
scriptTag: (script) => componentScriptTag(script, nonce),
|
|
137
|
+
...props,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** `GET {prefix}/{segment}` — renders an extension page. */
|
|
141
|
+
export async function showPage(ctx, page) {
|
|
142
|
+
const found = await requestOr404(ctx, page, false);
|
|
143
|
+
if (!found)
|
|
144
|
+
return;
|
|
145
|
+
const query = validatedQuery(ctx.request.qs());
|
|
146
|
+
const resolved = await loadResolvedPage(found.request, found.page, query);
|
|
147
|
+
return render(ctx, found.request, {
|
|
148
|
+
page: resolved,
|
|
149
|
+
step: null,
|
|
150
|
+
flash: takeFlash(ctx),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** `GET {prefix}/{segment}/actions/:action/confirm` — the no-JS confirmation step. */
|
|
154
|
+
export async function showActionStep(ctx, page) {
|
|
155
|
+
const found = await requestOr404(ctx, page, false);
|
|
156
|
+
if (!found)
|
|
157
|
+
return;
|
|
158
|
+
const name = String(ctx.request.param('action') ?? '');
|
|
159
|
+
const action = actionOf(found.page, name);
|
|
160
|
+
if (!action)
|
|
161
|
+
return notFound(ctx, false);
|
|
162
|
+
const t = translatorFor(found.request, found.page);
|
|
163
|
+
const target = typeof ctx.request.qs().target === 'string' ? ctx.request.qs().target : null;
|
|
164
|
+
const button = resolveAction(found.page, { action: name, ...(target ? { target } : {}) }, t);
|
|
165
|
+
const step = {
|
|
166
|
+
action: name,
|
|
167
|
+
title: t(action.label),
|
|
168
|
+
text: action.confirm !== undefined ? t(action.confirm) : null,
|
|
169
|
+
tone: action.tone === 'danger' ? 'danger' : 'default',
|
|
170
|
+
sudo: action.sudo === true,
|
|
171
|
+
href: button ? button.href.replace(/\/confirm(\?.*)?$/, '') : pageHref(found.page),
|
|
172
|
+
target,
|
|
173
|
+
cancelHref: pageHref(found.page),
|
|
174
|
+
submitLabel: t(action.label),
|
|
175
|
+
fields: resolveFields(action, t),
|
|
176
|
+
};
|
|
177
|
+
const resolved = {
|
|
178
|
+
id: found.page.page.id,
|
|
179
|
+
extension: found.page.extensionId,
|
|
180
|
+
title: t(found.page.page.title),
|
|
181
|
+
href: pageHref(found.page),
|
|
182
|
+
tabs: [],
|
|
183
|
+
blocks: [],
|
|
184
|
+
};
|
|
185
|
+
return render(ctx, found.request, {
|
|
186
|
+
page: resolved,
|
|
187
|
+
step,
|
|
188
|
+
flash: takeFlash(ctx),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** The CSP nonce shield put on this response, when there is one. */
|
|
192
|
+
export function cspNonceOf(ctx) {
|
|
193
|
+
const nonce = ctx.response?.nonce;
|
|
194
|
+
return typeof nonce === 'string' && nonce !== '' ? nonce : null;
|
|
195
|
+
}
|
|
196
|
+
async function runAction(ctx, request, page, name, raw, json) {
|
|
197
|
+
const t = translatorFor(request, page);
|
|
198
|
+
const action = actionOf(page, name);
|
|
199
|
+
const back = pageHref(page);
|
|
200
|
+
if (!action) {
|
|
201
|
+
const text = t({ key: 'account.extension.action_not_found' });
|
|
202
|
+
return {
|
|
203
|
+
status: 404,
|
|
204
|
+
body: { ok: false, error: apiError('not_found', text).error },
|
|
205
|
+
location: back,
|
|
206
|
+
flash: { tone: 'danger', text },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const sensitive = action.sudo === true || action.tone === 'danger';
|
|
210
|
+
if (sensitive && request.base.impersonatedBy !== null) {
|
|
211
|
+
const text = t({ key: 'account.extension.impersonation_refused' });
|
|
212
|
+
return {
|
|
213
|
+
status: 403,
|
|
214
|
+
body: { ok: false, error: apiError('impersonation_refused', text).error },
|
|
215
|
+
location: back,
|
|
216
|
+
flash: { tone: 'danger', text },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (action.sudo === true) {
|
|
220
|
+
const settings = await resolveRuntimeSettings(ctx);
|
|
221
|
+
if (!(await isSudoSatisfied(ctx, settings)))
|
|
222
|
+
return 'sudo';
|
|
223
|
+
}
|
|
224
|
+
const input = validateActionInput(action, raw, t);
|
|
225
|
+
// The step keeps the posted target even when the fields were refused, so the user can fix them.
|
|
226
|
+
const rawTarget = typeof raw.target === 'string' && raw.target !== '' && raw.target.length <= 512
|
|
227
|
+
? raw.target
|
|
228
|
+
: null;
|
|
229
|
+
const button = resolveAction(page, { action: name, ...(rawTarget ? { target: rawTarget } : {}) }, t);
|
|
230
|
+
const stepHref = button?.step ? button.href : back;
|
|
231
|
+
if (!input.ok) {
|
|
232
|
+
return {
|
|
233
|
+
status: 422,
|
|
234
|
+
body: { ok: false, error: apiError('invalid_input', input.error).error },
|
|
235
|
+
location: stepHref,
|
|
236
|
+
flash: { tone: 'danger', text: input.error },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
let result;
|
|
240
|
+
try {
|
|
241
|
+
result = await withTimeout(action.handle(extensionContext(request, {}), { target: input.target, fields: input.fields }), request.config.actionTimeoutMs ?? 15_000, 'handle()');
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
logExtensionError(page, `actions.${name}.handle`, error);
|
|
245
|
+
result = { ok: false, error: { key: 'account.extension.action_failed' } };
|
|
246
|
+
}
|
|
247
|
+
const audit = async (type, extra = {}) => {
|
|
248
|
+
try {
|
|
249
|
+
await request.cfg.audit?.record({
|
|
250
|
+
type,
|
|
251
|
+
accountId: request.base.account.id,
|
|
252
|
+
ip: ctx.request.ip?.() ?? null,
|
|
253
|
+
metadata: { page: page.page.id, action: name, target: input.target, ...extra },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* best effort */
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
if (sensitive)
|
|
261
|
+
await audit('account.extension.action', { ok: result?.ok === true });
|
|
262
|
+
if (!result || result.ok !== true) {
|
|
263
|
+
const failure = result;
|
|
264
|
+
const text = t(failure?.error ?? { key: 'account.extension.action_failed' });
|
|
265
|
+
const status = [400, 403, 404, 409].includes(failure?.status ?? 0)
|
|
266
|
+
? failure?.status
|
|
267
|
+
: 400;
|
|
268
|
+
return {
|
|
269
|
+
status,
|
|
270
|
+
body: { ok: false, error: apiError('action_failed', text).error },
|
|
271
|
+
location: json ? back : button?.step ? stepHref : back,
|
|
272
|
+
flash: { tone: 'danger', text },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
let location = back;
|
|
276
|
+
let external = false;
|
|
277
|
+
const redirectTo = result.redirectTo;
|
|
278
|
+
if (redirectTo && 'page' in redirectTo) {
|
|
279
|
+
const other = findPageById(redirectTo.page);
|
|
280
|
+
if (other)
|
|
281
|
+
location = pageHref(other);
|
|
282
|
+
}
|
|
283
|
+
else if (redirectTo && 'external' in redirectTo) {
|
|
284
|
+
const href = safeHref(redirectTo.external, true);
|
|
285
|
+
if (href) {
|
|
286
|
+
location = href;
|
|
287
|
+
external = true;
|
|
288
|
+
await audit('account.extension.redirect', { url: href });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const text = t(result.flash ?? { key: 'account.extension.done' });
|
|
292
|
+
return {
|
|
293
|
+
status: 200,
|
|
294
|
+
body: { ok: true, flash: text, redirectTo: location === back ? null : location, external },
|
|
295
|
+
location,
|
|
296
|
+
flash: external ? null : { tone: 'success', text },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function sudoUrl(returnTo) {
|
|
300
|
+
return `${accountPath('confirm')}?return_to=${encodeURIComponent(returnTo)}`;
|
|
301
|
+
}
|
|
302
|
+
/** `POST {prefix}/{segment}/actions/:action` — a form submission (`_csrf` checked by shield). */
|
|
303
|
+
export async function postAction(ctx, page) {
|
|
304
|
+
const found = await requestOr404(ctx, page, false);
|
|
305
|
+
if (!found)
|
|
306
|
+
return;
|
|
307
|
+
const name = String(ctx.request.param('action') ?? '');
|
|
308
|
+
const body = ctx.request.body();
|
|
309
|
+
const fields = {};
|
|
310
|
+
for (const [key, value] of Object.entries(body)) {
|
|
311
|
+
if (key.startsWith('field_'))
|
|
312
|
+
fields[key.slice('field_'.length)] = value;
|
|
313
|
+
}
|
|
314
|
+
const outcome = await runAction(ctx, found.request, found.page, name, { target: body.target, fields }, false);
|
|
315
|
+
if (outcome === 'sudo') {
|
|
316
|
+
const t = translatorFor(found.request, found.page);
|
|
317
|
+
const target = typeof body.target === 'string' ? body.target : undefined;
|
|
318
|
+
const button = resolveAction(found.page, { action: name, ...(target ? { target } : {}) }, t);
|
|
319
|
+
return redirectExact(ctx.response, sudoUrl(button?.step ? button.href : pageHref(found.page)));
|
|
320
|
+
}
|
|
321
|
+
if (outcome.flash)
|
|
322
|
+
flash(ctx, outcome.flash.tone, outcome.flash.text);
|
|
323
|
+
return redirectExact(ctx.response, outcome.location);
|
|
324
|
+
}
|
|
325
|
+
// ── JSON API ──────────────────────────────────────────────────────────────────────────────────────
|
|
326
|
+
/** `GET {prefix}/api/extensions` — the navigation for React hosts. */
|
|
327
|
+
export async function apiExtensions(ctx) {
|
|
328
|
+
const nav = await buildAccountNav(ctx, null);
|
|
329
|
+
const ids = new Set(accountExtensionPages().map((p) => p.page.id));
|
|
330
|
+
return ctx.response.send({
|
|
331
|
+
nav,
|
|
332
|
+
pages: nav
|
|
333
|
+
.filter((entry) => ids.has(entry.id))
|
|
334
|
+
.map(({ id, label, href, order, group }) => ({ id, label, href, order, group })),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** `GET {prefix}/api/x/:pageId` — one page, resolved. */
|
|
338
|
+
export async function apiPage(ctx) {
|
|
339
|
+
const found = await requestOr404(ctx, findPageById(String(ctx.request.param('pageId'))), true);
|
|
340
|
+
if (!found)
|
|
341
|
+
return;
|
|
342
|
+
const query = validatedQuery(ctx.request.qs());
|
|
343
|
+
const page = await loadResolvedPage(found.request, found.page, query);
|
|
344
|
+
const t = translatorFor(found.request, found.page);
|
|
345
|
+
const actions = {};
|
|
346
|
+
for (const [name, action] of Object.entries(found.page.page.actions ?? {})) {
|
|
347
|
+
actions[name] = definitionOf(action, t);
|
|
348
|
+
}
|
|
349
|
+
return ctx.response.header('cache-control', 'private, no-store').send({ page, actions });
|
|
350
|
+
}
|
|
351
|
+
/** `POST {prefix}/api/x/:pageId/actions/:action` — runs an action, answers JSON. */
|
|
352
|
+
export async function apiAction(ctx) {
|
|
353
|
+
const found = await requestOr404(ctx, findPageById(String(ctx.request.param('pageId'))), true);
|
|
354
|
+
if (!found)
|
|
355
|
+
return;
|
|
356
|
+
const name = String(ctx.request.param('action') ?? '');
|
|
357
|
+
const body = ctx.request.body();
|
|
358
|
+
const outcome = await runAction(ctx, found.request, found.page, name, body, true);
|
|
359
|
+
if (outcome === 'sudo') {
|
|
360
|
+
// Back to the action's confirmation step (target included) when it has one, like the forms.
|
|
361
|
+
const t = translatorFor(found.request, found.page);
|
|
362
|
+
const target = typeof body.target === 'string' && body.target !== '' ? body.target : undefined;
|
|
363
|
+
const button = resolveAction(found.page, { action: name, ...(target ? { target } : {}) }, t);
|
|
364
|
+
return ctx.response.status(403).send(apiError('sudo_required', 'Identity confirmation required.', {
|
|
365
|
+
confirmUrl: sudoUrl(button?.step ? button.href : pageHref(found.page)),
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
return ctx.response.status(outcome.status).send(outcome.body);
|
|
369
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
2
|
+
import type { AccountExtensionOverride } from './extension.js';
|
|
3
|
+
import { AccountExtensionRegistryImpl } from './registry.js';
|
|
4
|
+
import { type BuiltinNavId } from './runtime.js';
|
|
5
|
+
type Middleware = (ctx: HttpContext, next: () => Promise<void>) => Promise<unknown>;
|
|
6
|
+
/** The process registry bound at `authkit.accountExtensions`. */
|
|
7
|
+
export declare function accountExtensionRegistry(): AccountExtensionRegistryImpl;
|
|
8
|
+
/** A fresh registry (tests). */
|
|
9
|
+
export declare function resetAccountExtensionRegistry(next?: AccountExtensionRegistryImpl): void;
|
|
10
|
+
/** One extension page in {@link AuthHostRouteMap}. */
|
|
11
|
+
export interface AccountExtensionRouteEntry {
|
|
12
|
+
id: string;
|
|
13
|
+
href: string;
|
|
14
|
+
label: string;
|
|
15
|
+
order: number;
|
|
16
|
+
}
|
|
17
|
+
export interface MountAccountExtensionsOptions {
|
|
18
|
+
guard: Middleware;
|
|
19
|
+
/** Per-account bucket for action POSTs: `[html, json]` variants (same count). */
|
|
20
|
+
throttle?: {
|
|
21
|
+
html: Middleware;
|
|
22
|
+
json: Middleware;
|
|
23
|
+
};
|
|
24
|
+
overrides?: Readonly<Record<string, AccountExtensionOverride>>;
|
|
25
|
+
builtins: Partial<Record<BuiltinNavId, string>>;
|
|
26
|
+
/** Filled in place once the routes are mounted. */
|
|
27
|
+
entries: AccountExtensionRouteEntry[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Mounts the JSON API of the extensions now, and the page routes once every provider booted
|
|
31
|
+
* (contributors register from their `boot()`; pages mount when the app starts), then freezes the
|
|
32
|
+
* registry.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mountAccountExtensions(router: any, options: MountAccountExtensionsOptions): void;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { getBootedApp } from '../../services/booted_app.js';
|
|
2
|
+
import { accountPathsMap, accountPrefix, joinAccountPath } from '../host/account_paths.js';
|
|
3
|
+
import { AccountExtensionRegistryImpl } from './registry.js';
|
|
4
|
+
import { accountExtensionPages, setAccountConsoleState } from './runtime.js';
|
|
5
|
+
function inProduction() {
|
|
6
|
+
try {
|
|
7
|
+
return getBootedApp().inProduction === true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function lifecycleApp() {
|
|
14
|
+
try {
|
|
15
|
+
return getBootedApp();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
let registry = null;
|
|
22
|
+
/** The process registry bound at `authkit.accountExtensions`. */
|
|
23
|
+
export function accountExtensionRegistry() {
|
|
24
|
+
registry ??= new AccountExtensionRegistryImpl({ production: inProduction });
|
|
25
|
+
return registry;
|
|
26
|
+
}
|
|
27
|
+
/** A fresh registry (tests). */
|
|
28
|
+
export function resetAccountExtensionRegistry(next) {
|
|
29
|
+
registry = next ?? null;
|
|
30
|
+
}
|
|
31
|
+
const H = () => import('./http.js');
|
|
32
|
+
function builtinSegments() {
|
|
33
|
+
const prefix = accountPrefix() === '/' ? '' : accountPrefix();
|
|
34
|
+
return Object.values(accountPathsMap()).map((path) => (path.startsWith(`${prefix}/`) ? path.slice(prefix.length + 1) : path).split('/')[0] ?? '');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Mounts the JSON API of the extensions now, and the page routes once every provider booted
|
|
38
|
+
* (contributors register from their `boot()`; pages mount when the app starts), then freezes the
|
|
39
|
+
* registry.
|
|
40
|
+
*/
|
|
41
|
+
export function mountAccountExtensions(router, options) {
|
|
42
|
+
const apiBase = joinAccountPath('api');
|
|
43
|
+
const withJsonThrottle = (route) => {
|
|
44
|
+
if (options.throttle)
|
|
45
|
+
route.use([options.throttle.json]);
|
|
46
|
+
return route;
|
|
47
|
+
};
|
|
48
|
+
router
|
|
49
|
+
.group(() => {
|
|
50
|
+
router.get(`${apiBase}/extensions`, async (ctx) => (await H()).apiExtensions(ctx));
|
|
51
|
+
router.get(`${apiBase}/x/:pageId`, async (ctx) => (await H()).apiPage(ctx));
|
|
52
|
+
withJsonThrottle(router.post(`${apiBase}/x/:pageId/actions/:action`, async (ctx) => (await H()).apiAction(ctx)));
|
|
53
|
+
})
|
|
54
|
+
.use([options.guard]);
|
|
55
|
+
const mountPages = () => {
|
|
56
|
+
const reg = accountExtensionRegistry();
|
|
57
|
+
const pages = reg.freeze({
|
|
58
|
+
overrides: options.overrides,
|
|
59
|
+
builtinSegments: builtinSegments(),
|
|
60
|
+
production: inProduction(),
|
|
61
|
+
});
|
|
62
|
+
setAccountConsoleState({ pages, builtins: options.builtins, registry: reg });
|
|
63
|
+
if (pages.length === 0)
|
|
64
|
+
return;
|
|
65
|
+
router
|
|
66
|
+
.group(() => {
|
|
67
|
+
for (const page of pages) {
|
|
68
|
+
const base = joinAccountPath(page.segment);
|
|
69
|
+
router.get(base, async (ctx) => (await H()).showPage(ctx, page));
|
|
70
|
+
router.get(`${base}/actions/:action/confirm`, async (ctx) => (await H()).showActionStep(ctx, page));
|
|
71
|
+
const post = router.post(`${base}/actions/:action`, async (ctx) => (await H()).postAction(ctx, page));
|
|
72
|
+
if (options.throttle)
|
|
73
|
+
post.use([options.throttle.html]);
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.use([options.guard]);
|
|
77
|
+
for (const page of accountExtensionPages()) {
|
|
78
|
+
if (page.page.nav === false)
|
|
79
|
+
continue;
|
|
80
|
+
const label = page.page.nav?.label ?? page.page.title;
|
|
81
|
+
options.entries.push({
|
|
82
|
+
id: page.page.id,
|
|
83
|
+
href: joinAccountPath(page.segment),
|
|
84
|
+
label: typeof label === 'string' ? label : label.key,
|
|
85
|
+
order: page.order,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
setAccountConsoleState({ builtins: options.builtins, registry: accountExtensionRegistry() });
|
|
90
|
+
// During the providers' boot (auto-mount from `config.routes`) contributors may not have
|
|
91
|
+
// registered yet: mount the pages when the app starts, before the preloads and the router commit.
|
|
92
|
+
const app = lifecycleApp();
|
|
93
|
+
if (app && app.isBooted === false && typeof app.starting === 'function') {
|
|
94
|
+
app.starting(mountPages);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
mountPages();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type AccountExtension, type AccountExtensionOverride, type AccountExtensionRegistry, type AccountPage } from './extension.js';
|
|
2
|
+
/** A page as the console serves it, after host overrides. */
|
|
3
|
+
export interface RegisteredAccountPage {
|
|
4
|
+
extensionId: string;
|
|
5
|
+
page: AccountPage;
|
|
6
|
+
segment: string;
|
|
7
|
+
order: number;
|
|
8
|
+
scriptOrigins: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** Thrown for an invalid extension; the message names it. */
|
|
11
|
+
export declare class AccountExtensionError extends Error {
|
|
12
|
+
}
|
|
13
|
+
/** Validates an extension's own shape (no knowledge of the other extensions). */
|
|
14
|
+
export declare function validateAccountExtension(extension: AccountExtension): void;
|
|
15
|
+
export interface FreezeOptions {
|
|
16
|
+
/** `accountConsole.extensions` from `config/authkit.ts`. */
|
|
17
|
+
overrides?: Readonly<Record<string, AccountExtensionOverride>>;
|
|
18
|
+
/** Segments of the mounted built-in screens (after `accountRoutes` renames). */
|
|
19
|
+
builtinSegments?: readonly string[];
|
|
20
|
+
/** `true` → an invalid extension is skipped and logged instead of thrown. */
|
|
21
|
+
production: boolean;
|
|
22
|
+
log?: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The registry bound at `authkit.accountExtensions`. Contributors call `register()` from their
|
|
26
|
+
* provider's `boot()`; authkit freezes it when it mounts the console routes.
|
|
27
|
+
*/
|
|
28
|
+
export declare class AccountExtensionRegistryImpl implements AccountExtensionRegistry {
|
|
29
|
+
#private;
|
|
30
|
+
readonly contract = 1;
|
|
31
|
+
readonly blockKinds: readonly string[];
|
|
32
|
+
constructor(options?: {
|
|
33
|
+
production?: () => boolean;
|
|
34
|
+
log?: (message: string) => void;
|
|
35
|
+
});
|
|
36
|
+
/** Adds an extension. Throws outside production on an invalid one; skips it in production. */
|
|
37
|
+
register(extension: AccountExtension): void;
|
|
38
|
+
get frozen(): boolean;
|
|
39
|
+
/** The registered extensions, in registration order. */
|
|
40
|
+
extensions(): readonly AccountExtension[];
|
|
41
|
+
/**
|
|
42
|
+
* Applies the host overrides, checks the segments against the mounted screens and freezes the
|
|
43
|
+
* registry. Returns the pages to serve. Idempotent.
|
|
44
|
+
*/
|
|
45
|
+
freeze(options: FreezeOptions): RegisteredAccountPage[];
|
|
46
|
+
/** The served pages (empty until frozen). */
|
|
47
|
+
pages(): readonly RegisteredAccountPage[];
|
|
48
|
+
}
|