@jini-ai/admin 0.6.15-rc.2 → 0.6.16
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/dist/agent-plugins/adapters/memory.js +9 -3
- package/dist/agent-plugins/conformance/agent-plugins-api.conformance.js +1 -1
- package/dist/agent-plugins/controllers/agent-plugins.controller.js +14 -5
- package/dist/agent-plugins/package-file-tree.js +4 -6
- package/dist/agent-plugins/react/components/AgentPluginInspector.js +5 -2
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +10 -4
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +8 -5
- package/dist/agent-plugins/rules.js +3 -1
- package/dist/comments/adapters/http.js +2 -1
- package/dist/comments/adapters/memory.js +3 -1
- package/dist/comments/conformance/comments-api.conformance.js +3 -2
- package/dist/comments/controllers/comment-queue.controller.d.ts +1 -1
- package/dist/comments/controllers/comment-queue.controller.js +4 -2
- package/dist/core/entities/rules.js +13 -20
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/manifest/rules.d.ts +20 -0
- package/dist/core/manifest/rules.js +34 -0
- package/dist/core/manifest/types.d.ts +9 -0
- package/dist/core/module/create-admin.js +2 -1
- package/dist/core/omit-keys.d.ts +7 -0
- package/dist/core/omit-keys.js +12 -0
- package/dist/core/ports/forms.d.ts +5 -3
- package/dist/core/transport/query-suffix.d.ts +3 -0
- package/dist/core/transport/query-suffix.js +5 -0
- package/dist/forms/adapters/http.js +3 -2
- package/dist/forms/adapters/memory.js +3 -2
- package/dist/forms/controllers/field-attributes-dialog.controller.js +17 -3
- package/dist/forms/controllers/form-fields-editor.controller.js +1 -2
- package/dist/forms/controllers/form-submission-detail.controller.js +1 -2
- package/dist/forms/controllers/form-submissions.controller.js +1 -2
- package/dist/forms/controllers/forms-list.controller.js +1 -2
- package/dist/forms/field-settings.js +15 -4
- package/dist/forms/react/hooks/forms-view.hooks.d.ts +2 -2
- package/dist/forms/react/hooks/forms-view.hooks.js +9 -1
- package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +1 -3
- package/dist/forms/react/hooks/use-form-editor.hooks.js +8 -9
- package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -4
- package/dist/forms/react/hooks/use-form-submissions.hooks.js +1 -5
- package/dist/forms/react/hooks/use-forms-list.hooks.js +1 -2
- package/dist/forms/rules.js +7 -2
- package/dist/media/adapters/memory.js +5 -3
- package/dist/media/controllers/library.controller.js +4 -2
- package/dist/media/controllers/providers.controller.js +2 -1
- package/dist/media/react/components/MediaCard.js +11 -1
- package/dist/media/react/hooks/EditMediaPanel.hooks.js +18 -7
- package/dist/media/react/hooks/LibraryTab.hooks.js +3 -2
- package/dist/media/react/hooks/ProvidersTab.hooks.js +19 -9
- package/dist/media/rules.js +6 -3
- package/dist/playground/adapters/memory.js +26 -7
- package/dist/playground/conformance/playground-api.conformance.js +6 -3
- package/dist/playground/react/hooks/PlaygroundPorts.hooks.js +6 -2
- package/dist/react/components/ConfirmButton/ConfirmButton.hooks.js +1 -1
- package/dist/react/components/ConfirmButton/ConfirmButton.js +2 -5
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +2 -2
- package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +3 -1
- package/dist/react/components/RowMenu/RowMenu.hooks.js +4 -8
- package/dist/react/components/Sidebar.js +1 -1
- package/dist/react/entities/EntityField.js +1 -0
- package/dist/react/entities/contribution.js +3 -1
- package/dist/react/entities/types.js +1 -1
- package/dist/react/overlays.js +2 -1
- package/dist/react/shell/AdminShell.hooks.js +1 -1
- package/dist/react/shell/use-admin-shell-session.js +2 -1
- package/dist/react/shell/use-admin-theme.js +6 -6
- package/dist/redirects/adapters/memory.js +16 -13
- package/dist/security/adapters/http.js +10 -11
- package/dist/security/adapters/memory.js +50 -15
- package/dist/security/conformance/security-api.conformance.js +6 -3
- package/dist/security/controllers/access-tokens.controller.js +7 -3
- package/dist/security/controllers/other-credentials.controller.js +4 -2
- package/dist/security/controllers/root-key.controller.js +12 -5
- package/dist/security/react/hooks/AccessTokensTab.hooks.js +8 -3
- package/dist/security/react/hooks/SecurityPorts.hooks.js +6 -2
- package/dist/security/rules.js +19 -5
- package/dist/seo/adapters/memory.js +5 -2
- package/dist/seo/controllers/seo-entry.controller.js +2 -1
- package/dist/seo/controllers/sitemap.controller.js +2 -1
- package/dist/seo/react/hooks/MediaPickerBridge.hooks.js +1 -1
- package/dist/seo/react/hooks/use-seo.hooks.js +1 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -1
- package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +9 -1
- package/dist/seo/react/pages/SeoPage.js +7 -1
- package/dist/source-control/adapters/memory.js +30 -14
- package/dist/source-control/controllers/source-control.controller.js +5 -2
- package/dist/source-control/react/components/SourceControlProviderRow.js +6 -1
- package/dist/source-control/react/hooks/SourceControlPorts.hooks.js +6 -2
- package/dist/source-control/rules.js +9 -3
- package/dist/widgets/adapters/http.js +2 -1
- package/dist/widgets/adapters/memory.js +4 -2
- package/dist/widgets/conformance/widgets-api.conformance.js +5 -2
- package/dist/widgets/controllers/instance-editor.controller.js +3 -2
- package/dist/widgets/controllers/library.controller.js +2 -1
- package/dist/widgets/controllers/region-editor.controller.js +2 -1
- package/dist/widgets/controllers/regions.controller.js +2 -1
- package/dist/widgets/react/components/WidgetInstanceNotices.d.ts +1 -1
- package/dist/widgets/react/components/WidgetInstanceNotices.js +2 -0
- package/dist/widgets/react/hooks/use-widget-regions.hooks.js +5 -2
- package/dist/widgets/react/hooks/use-widgets-library.hooks.js +5 -2
- package/dist/widgets/react/pages/WidgetInstanceEditor.js +1 -1
- package/dist/widgets/react/pages/WidgetRegionEditor.js +1 -1
- package/dist/widgets/react/pages/WidgetsLibrary.js +1 -1
- package/dist/widgets/rules.js +5 -1
- package/package.json +47 -37
|
@@ -154,7 +154,9 @@ function parseEmbedConfig(raw) {
|
|
|
154
154
|
}
|
|
155
155
|
function galleryIdentityLabel(config) {
|
|
156
156
|
const count = Array.isArray(config.images) ? config.images.length : 0;
|
|
157
|
-
|
|
157
|
+
if (count === 0)
|
|
158
|
+
return 'no images set';
|
|
159
|
+
return count === 1 ? '1 image' : `${count} images`;
|
|
158
160
|
}
|
|
159
161
|
export function InteractiveHtmlEditor(props) {
|
|
160
162
|
return (_jsx(HtmlEditor, { ...props, isProtectedElement: (el) => isProtectedEmbedElement({ el }), describeEmbedPlaceholder: (el) => describeEmbedPlaceholder({ el }) }));
|
|
@@ -121,14 +121,10 @@ export function useRowMenu({ itemCount, window, document }) {
|
|
|
121
121
|
openAt(0);
|
|
122
122
|
}
|
|
123
123
|
function onTriggerKeyDown(e) {
|
|
124
|
-
if (e.key
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
else if (e.key === 'ArrowUp') {
|
|
129
|
-
e.preventDefault();
|
|
130
|
-
openAt(itemCount - 1);
|
|
131
|
-
}
|
|
124
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')
|
|
125
|
+
return;
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
openAt(e.key === 'ArrowDown' ? 0 : itemCount - 1);
|
|
132
128
|
}
|
|
133
129
|
function onMenuKeyDown(e) {
|
|
134
130
|
switch (e.key) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
4
|
import { DEFAULT_ADMIN_BASE, adminHref } from '../../core/routing/rules.js';
|
|
5
5
|
import { useNavSections } from '../hooks/use-nav-sections.js';
|
|
@@ -43,6 +43,7 @@ export function EntityField(props) {
|
|
|
43
43
|
case 'integer':
|
|
44
44
|
case 'real': return _jsx(NumberInput, { ...props });
|
|
45
45
|
case 'text': return _jsx("input", { ...commonProps(props), value: typeof value === 'string' ? value : '', onChange: (e) => onChange({ value: blankToUndefined(e.target.value) }) });
|
|
46
|
+
default: return null;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
//# sourceMappingURL=EntityField.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { trimCharsEnd } from '@jini-ai/core/text';
|
|
2
3
|
import { EntityIndex } from './EntityIndex.js';
|
|
3
4
|
import { EntityList } from './EntityList.js';
|
|
4
5
|
import { EntityDetail } from './EntityDetail.js';
|
|
@@ -18,7 +19,7 @@ export function createEntityRoutes({ adminBase, panelId, navigate }) {
|
|
|
18
19
|
throw new Error('Entity panelId must be one URL segment');
|
|
19
20
|
if (!adminBase.startsWith('/') || adminBase.startsWith('//') || /[?#]/.test(adminBase))
|
|
20
21
|
throw new Error('Entity adminBase must be an absolute pathname');
|
|
21
|
-
const base =
|
|
22
|
+
const base = trimCharsEnd({ text: adminBase, chars: '/' });
|
|
22
23
|
function routePath({ entity, id, view }) {
|
|
23
24
|
const root = `/${panelId}`;
|
|
24
25
|
if (entity === undefined)
|
|
@@ -45,6 +46,7 @@ export function createEntityPanel(args, options = {}) {
|
|
|
45
46
|
const dependencies = { registry: args.registry, translate: args.translate, routes };
|
|
46
47
|
return {
|
|
47
48
|
id: args.panelId,
|
|
49
|
+
description: args.translate({ key: 'Browse and edit data records.' }),
|
|
48
50
|
nav: options.nav ?? { label: args.translate({ key: 'Data' }) },
|
|
49
51
|
agentReachable: options.agentReachable ?? false,
|
|
50
52
|
...(options.requires === undefined ? {} : { requires: options.requires }),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Stable source templates let host dictionaries translate copy before values are inserted. */
|
|
2
2
|
export function entityText({ translate, key, values }) {
|
|
3
|
-
return translate({ key: key }).replace(/\{(
|
|
3
|
+
return translate({ key: key }).replace(/\{(\w+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
6
|
* Adapts the host's object-arg {@link EntityTranslate} to the positional `(key) => string` form the
|
package/dist/react/overlays.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createControllerStore } from '../core/module/controller-store.js';
|
|
|
4
4
|
export function createOverlayController(_required = {}, _optional = {}) {
|
|
5
5
|
const store = createControllerStore({ initial: { entries: [] } });
|
|
6
6
|
let sequence = 0, cancelActive = null;
|
|
7
|
+
const nextSequence = () => { sequence += 1; return sequence; };
|
|
7
8
|
return {
|
|
8
9
|
getSnapshot: store.getSnapshot,
|
|
9
10
|
subscribe: store.subscribe,
|
|
@@ -28,7 +29,7 @@ export function createOverlayController(_required = {}, _optional = {}) {
|
|
|
28
29
|
signal?.addEventListener('abort', cancel, { once: true });
|
|
29
30
|
store.set({
|
|
30
31
|
patch: {
|
|
31
|
-
entries: [{ id:
|
|
32
|
+
entries: [{ id: nextSequence(), render: () => render({ resolve: finish, cancel }) }],
|
|
32
33
|
},
|
|
33
34
|
});
|
|
34
35
|
});
|
|
@@ -39,7 +39,8 @@ export function useAdminShellSession(args) {
|
|
|
39
39
|
return;
|
|
40
40
|
if (operation === 'logout')
|
|
41
41
|
loggingOut.current = owner;
|
|
42
|
-
|
|
42
|
+
generation.current += 1;
|
|
43
|
+
const request = generation.current;
|
|
43
44
|
const isCurrent = () => active.current === owner && generation.current === request;
|
|
44
45
|
setOwned({ context, port, state: { status: 'checking' } });
|
|
45
46
|
// settleOperation never rejects, so the logout latch below always clears.
|
|
@@ -4,7 +4,9 @@ import { applyAdminTheme, resolveColorScheme } from '@jini-ai/ui/theme';
|
|
|
4
4
|
export function useAdminTheme({ userId, workspace }, { theme, colorScheme, environment, preferenceStore, onColorSchemeChange } = {}) {
|
|
5
5
|
const scope = JSON.stringify([userId, Object.entries(workspace).sort(([a], [b]) => a.localeCompare(b))]);
|
|
6
6
|
const [saved, setSaved] = useState(null);
|
|
7
|
-
|
|
7
|
+
// Only the setter is used: bumping the revision re-renders, which re-reads the live media
|
|
8
|
+
// snapshot below; explicit choices never query the OS.
|
|
9
|
+
const [, setMediaRevision] = useState(0);
|
|
8
10
|
const preference = colorScheme ?? (saved?.scope === scope ? saved.preference : 'system');
|
|
9
11
|
useEffect(() => {
|
|
10
12
|
if (!userId || !preferenceStore) {
|
|
@@ -16,25 +18,23 @@ export function useAdminTheme({ userId, workspace }, { theme, colorScheme, envir
|
|
|
16
18
|
}, [userId, workspace, scope, preferenceStore]);
|
|
17
19
|
useEffect(() => {
|
|
18
20
|
if (!theme)
|
|
19
|
-
return;
|
|
21
|
+
return undefined;
|
|
20
22
|
if (!environment)
|
|
21
23
|
throw new TypeError('AdminShell theme requires a themeEnvironment port');
|
|
22
24
|
return applyAdminTheme({ theme }, environment);
|
|
23
25
|
}, [theme, environment]);
|
|
24
26
|
useEffect(() => {
|
|
25
27
|
if (!environment || preference !== 'system')
|
|
26
|
-
return;
|
|
28
|
+
return undefined;
|
|
27
29
|
const media = environment.matchMedia('(prefers-color-scheme: dark)');
|
|
28
30
|
const changed = () => setMediaRevision((revision) => revision + 1);
|
|
29
31
|
media.addEventListener?.('change', changed);
|
|
30
32
|
return () => media.removeEventListener?.('change', changed);
|
|
31
33
|
}, [environment, preference]);
|
|
32
|
-
// A revision refreshes the live media snapshot; explicit choices never query the OS.
|
|
33
|
-
void mediaRevision;
|
|
34
34
|
const resolved = environment ? resolveColorScheme({ preference }, { matchMedia: environment.matchMedia }) : 'light';
|
|
35
35
|
useEffect(() => {
|
|
36
36
|
if (!environment)
|
|
37
|
-
return;
|
|
37
|
+
return undefined;
|
|
38
38
|
const previous = environment.target.getAttribute('data-color-scheme');
|
|
39
39
|
environment.target.setAttribute('data-color-scheme', resolved);
|
|
40
40
|
return () => {
|
|
@@ -15,15 +15,22 @@ export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { valida
|
|
|
15
15
|
throw new AdminApiError({ message: `redirect '${id}' was not found`, status: 404 }, { code: 'REDIRECT_NOT_FOUND' });
|
|
16
16
|
return row;
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
/** The core port documents reference-host rejection of a regex `matchType`. A supplied
|
|
19
|
+
* validator owns alternate hosts and sees the whole candidate instead. */
|
|
20
|
+
function validateOrRejectRegex(candidate, matchType) {
|
|
21
|
+
if (validate) {
|
|
22
|
+
validate(candidate);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (matchType === 'regex')
|
|
23
26
|
throw new AdminApiError({ message: 'regex match type is not supported', status: 400 }, { code: 'REDIRECT_VALIDATION_ERROR' });
|
|
27
|
+
}
|
|
28
|
+
function create(input, source) {
|
|
29
|
+
validateOrRejectRegex(input, input.matchType);
|
|
24
30
|
let id;
|
|
25
31
|
do {
|
|
26
|
-
id = `memory-redirect-${nextId
|
|
32
|
+
id = `memory-redirect-${nextId}`;
|
|
33
|
+
nextId += 1;
|
|
27
34
|
} while (rows.has(id));
|
|
28
35
|
const row = Object.freeze({ ...input, id, status: 'active', override: input.override ?? false,
|
|
29
36
|
priority: input.priority ?? 0, source, sourceEntryId: null, fromPathAtCapture: null, toPathAtCapture: null,
|
|
@@ -34,20 +41,16 @@ export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { valida
|
|
|
34
41
|
}
|
|
35
42
|
return {
|
|
36
43
|
async listRedirects(_required, filter = {}) {
|
|
44
|
+
const matches = (wanted, actual) => wanted === undefined || actual === wanted;
|
|
37
45
|
return Object.freeze([...rows.values()].filter(row => !trashed.has(row.id) &&
|
|
38
|
-
(filter.status
|
|
39
|
-
(filter.source === undefined || row.source === filter.source) &&
|
|
40
|
-
(filter.matchType === undefined || row.matchType === filter.matchType)));
|
|
46
|
+
matches(filter.status, row.status) && matches(filter.source, row.source) && matches(filter.matchType, row.matchType)));
|
|
41
47
|
},
|
|
42
48
|
async getRedirect({ id }) { return get(id); },
|
|
43
49
|
async createRedirect(input, options = {}) { return create({ ...input, ...options }, 'manual'); },
|
|
44
50
|
async updateRedirect({ id }, patch = {}) {
|
|
45
51
|
const before = get(id);
|
|
46
52
|
const next = { ...before, ...patch };
|
|
47
|
-
|
|
48
|
-
validate(next);
|
|
49
|
-
else if (patch.matchType === 'regex')
|
|
50
|
-
throw new AdminApiError({ message: 'regex match type is not supported', status: 400 }, { code: 'REDIRECT_VALIDATION_ERROR' });
|
|
53
|
+
validateOrRejectRegex(next, patch.matchType);
|
|
51
54
|
const row = Object.freeze({ ...next, version: before.version + 1 });
|
|
52
55
|
rows.set(id, row);
|
|
53
56
|
if (patch.status === 'active')
|
|
@@ -40,15 +40,14 @@ export function createHttpSecurityApi({ transport, workspacePath }, _optional =
|
|
|
40
40
|
function mediaEntry({ entry }, { apiKey } = {}) {
|
|
41
41
|
return { ...(entry.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}), ...(entry.model !== undefined ? { model: entry.model } : {}), ...(apiKey !== undefined ? { apiKey } : {}) };
|
|
42
42
|
}
|
|
43
|
+
const MEDIA_PROVIDERS_PATH = '/media/providers';
|
|
43
44
|
/** Replacing the ENTIRE map requires sibling settings unchanged. Blank apiKey preserves their stored keys. */
|
|
44
45
|
function rebuildMedia(current, id, token) {
|
|
45
46
|
const next = {};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
next[key] = mediaEntry({ entry }, { apiKey: token });
|
|
51
|
-
}
|
|
47
|
+
// The replaced entry is dropped when no token is given; every sibling is echoed unchanged.
|
|
48
|
+
const kept = Object.entries(current).filter(([key]) => key !== id || token !== undefined);
|
|
49
|
+
for (const [key, entry] of kept)
|
|
50
|
+
next[key] = mediaEntry({ entry }, key === id && token !== undefined ? { apiKey: token } : {});
|
|
52
51
|
return next;
|
|
53
52
|
}
|
|
54
53
|
export function createHttpOtherCredentials({ transport, workspacePath }, { mediaLabels = {} } = {}) {
|
|
@@ -62,7 +61,7 @@ export function createHttpOtherCredentials({ transport, workspacePath }, { media
|
|
|
62
61
|
return r.data.isSet ? [{ store, id: store, label: singles[store].label, category: 'ai', configured: true, supportsReplace: true, updatedAt: r.data.updatedAt }] : [];
|
|
63
62
|
}
|
|
64
63
|
if (store === 'media-provider') {
|
|
65
|
-
const map = await request(
|
|
64
|
+
const map = await request(MEDIA_PROVIDERS_PATH, 'GET', options);
|
|
66
65
|
return Object.entries(map).filter(([, entry]) => entry.apiKeyConfigured).map(([id]) => ({ store, id, label: mediaLabels[id] ?? id, category: 'media', configured: true, supportsReplace: true }));
|
|
67
66
|
}
|
|
68
67
|
const { servers } = await request('/mcp-servers', 'GET', options);
|
|
@@ -74,16 +73,16 @@ export function createHttpOtherCredentials({ transport, workspacePath }, { media
|
|
|
74
73
|
if (store === 'external-mcp')
|
|
75
74
|
throw new Error('Edit the full server configuration on its own screen');
|
|
76
75
|
if (store === 'media-provider') {
|
|
77
|
-
const current = await request(
|
|
78
|
-
await request(
|
|
76
|
+
const current = await request(MEDIA_PROVIDERS_PATH, 'GET', options);
|
|
77
|
+
await request(MEDIA_PROVIDERS_PATH, 'PUT', options, rebuildMedia(current, id, token));
|
|
79
78
|
}
|
|
80
79
|
else
|
|
81
80
|
await request(singles[store].path, 'PUT', options, { apiKey: token });
|
|
82
81
|
},
|
|
83
82
|
async remove({ store, id }, options = {}) {
|
|
84
83
|
if (store === 'media-provider') {
|
|
85
|
-
const current = await request(
|
|
86
|
-
await request(
|
|
84
|
+
const current = await request(MEDIA_PROVIDERS_PATH, 'GET', options);
|
|
85
|
+
await request(MEDIA_PROVIDERS_PATH, 'PUT', options, rebuildMedia(current, id));
|
|
87
86
|
}
|
|
88
87
|
else if (store === 'external-mcp')
|
|
89
88
|
await request(`/mcp-servers/${encodeURIComponent(id)}`, 'DELETE', options);
|
|
@@ -11,6 +11,7 @@ function customProjection({ patch }, { initial } = {}) {
|
|
|
11
11
|
export function createMemorySecurityApi({ providers = [], rows = [] }, _optional = {}) {
|
|
12
12
|
let items = rows.map(r => Object.freeze({ ...r }));
|
|
13
13
|
let sequence = 0;
|
|
14
|
+
const nextSequence = () => { sequence += 1; return sequence; };
|
|
14
15
|
function store(kind) {
|
|
15
16
|
function isDefaultFor({ patch, providerId }, { initial } = {}) {
|
|
16
17
|
if (kind === 'custom')
|
|
@@ -24,8 +25,11 @@ export function createMemorySecurityApi({ providers = [], rows = [] }, _optional
|
|
|
24
25
|
...(kind === 'custom' ? customProjection({ patch }, { initial }) : {}),
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
|
-
function check(label, providerId, id) {
|
|
28
|
-
|
|
28
|
+
function check(label, providerId, id) {
|
|
29
|
+
const sameScope = (r) => r.kind === kind && r.id !== id && (kind === 'custom' || r.providerId === providerId);
|
|
30
|
+
if (items.some(r => sameScope(r) && r.label.toLowerCase() === label.toLowerCase()))
|
|
31
|
+
throw new Error('Duplicate label');
|
|
32
|
+
}
|
|
29
33
|
function insert(row) {
|
|
30
34
|
if (row.isDefault)
|
|
31
35
|
items = items.map(r => r.kind === kind && r.providerId === row.providerId ? Object.freeze({ ...r, isDefault: false }) : r);
|
|
@@ -34,10 +38,23 @@ export function createMemorySecurityApi({ providers = [], rows = [] }, _optional
|
|
|
34
38
|
}
|
|
35
39
|
return {
|
|
36
40
|
async list(_r, o = {}) { o.signal?.throwIfAborted(); return Object.freeze(items.filter(r => r.kind === kind)); },
|
|
37
|
-
async create(input, o = {}) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
async create(input, o = {}) {
|
|
42
|
+
o.signal?.throwIfAborted();
|
|
43
|
+
if (!Object.values(input.connection).some(v => v.trim()) || !input.label.trim())
|
|
44
|
+
throw new Error('Incomplete credential');
|
|
45
|
+
const providerId = kind === 'custom' ? '' : input.connection.providerId ?? '';
|
|
46
|
+
check(input.label, providerId);
|
|
47
|
+
return insert(project({ id: `credential-${nextSequence()}`, patch: input, providerId, label: input.label }));
|
|
48
|
+
},
|
|
49
|
+
async update({ id, patch }, o = {}) {
|
|
50
|
+
o.signal?.throwIfAborted();
|
|
51
|
+
const old = items.find(r => r.id === id && r.kind === kind);
|
|
52
|
+
if (!old)
|
|
53
|
+
throw new Error('Credential not found');
|
|
54
|
+
const label = patch.label ?? old.label;
|
|
55
|
+
check(label, old.providerId, id);
|
|
56
|
+
return insert(project({ id, patch, providerId: old.providerId, label }, { initial: old }));
|
|
57
|
+
},
|
|
41
58
|
async remove({ id }, o = {}) { o.signal?.throwIfAborted(); const old = items.find(r => r.id === id && r.kind === kind); items = items.filter(r => !(r.id === id && r.kind === kind)); if (old?.isDefault) {
|
|
42
59
|
const next = items.filter(r => r.kind === kind && r.providerId === old.providerId).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
43
60
|
if (next)
|
|
@@ -51,8 +68,13 @@ export function createMemoryOtherCredentials({ rows = [] }, _optional = {}) {
|
|
|
51
68
|
let items = rows.map(row => Object.freeze({ ...row }));
|
|
52
69
|
return {
|
|
53
70
|
async list({ store }, o = {}) { o.signal?.throwIfAborted(); return items.filter(r => r.store === store); },
|
|
54
|
-
async replace({ store, id, token }, o = {}) {
|
|
55
|
-
|
|
71
|
+
async replace({ store, id, token }, o = {}) {
|
|
72
|
+
o.signal?.throwIfAborted();
|
|
73
|
+
const row = items.find(r => r.store === store && r.id === id);
|
|
74
|
+
if (!row?.supportsReplace || !token.trim())
|
|
75
|
+
throw new Error('Replacement unavailable');
|
|
76
|
+
items = items.map(r => r === row ? Object.freeze({ ...r, configured: true, updatedAt: new Date().toISOString() }) : r);
|
|
77
|
+
},
|
|
56
78
|
async remove({ store, id }, o = {}) { o.signal?.throwIfAborted(); items = items.filter(r => r.store !== store || r.id !== id); },
|
|
57
79
|
};
|
|
58
80
|
}
|
|
@@ -61,14 +83,27 @@ export function createMemoryRootKey({ status = { active: false, source: 'none',
|
|
|
61
83
|
function activate(outcome) { current = Object.freeze({ ...current, active: true, state: 'active', source: 'file', fingerprint: 'memory-fingerprint' }); return { outcome, fingerprint: 'memory-fingerprint', keyFilePath: current.keyFilePath, runtimeMode: current.runtimeMode }; }
|
|
62
84
|
return {
|
|
63
85
|
async status(_r, o = {}) { o.signal?.throwIfAborted(); return current; },
|
|
64
|
-
async generate(_r, o = {}) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
async generate(_r, o = {}) {
|
|
87
|
+
o.signal?.throwIfAborted();
|
|
88
|
+
if (current.active)
|
|
89
|
+
return { ...activate('already-active') };
|
|
90
|
+
if (current.state !== 'missing')
|
|
91
|
+
throw new Error('Key dependent data');
|
|
92
|
+
return activate('created');
|
|
93
|
+
},
|
|
94
|
+
async importToken({ token }, o = {}) {
|
|
95
|
+
o.signal?.throwIfAborted();
|
|
96
|
+
if (!/^[0-9a-f]{64}$/i.test(token))
|
|
97
|
+
throw new Error('Invalid token');
|
|
98
|
+
return { ...activate('unlocked'), resealed: 0 };
|
|
99
|
+
},
|
|
69
100
|
async previewStartFresh(_r, o = {}) { o.signal?.throwIfAborted(); return preview; },
|
|
70
|
-
async startFresh({ confirm }, o = {}) {
|
|
71
|
-
|
|
101
|
+
async startFresh({ confirm }, o = {}) {
|
|
102
|
+
o.signal?.throwIfAborted();
|
|
103
|
+
if (confirm !== START_FRESH_CONFIRMATION)
|
|
104
|
+
throw new Error('Confirmation required');
|
|
105
|
+
return { ...activate('started-fresh'), discarded: preview.removes, kept: 0, affectedWebhooks: preview.affectedWebhooks, restorePointId: 'memory-restore-point' };
|
|
106
|
+
},
|
|
72
107
|
};
|
|
73
108
|
}
|
|
74
109
|
//# sourceMappingURL=memory.js.map
|
|
@@ -3,9 +3,12 @@ import { credentialStore } from '../rules.js';
|
|
|
3
3
|
export async function runSecurityApiConformance({ createApi, provider }, _optional = {}) {
|
|
4
4
|
const api = await createApi({}), target = credentialStore({ api, kind: provider.kind });
|
|
5
5
|
let passed = 0;
|
|
6
|
-
function check(value, message) {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
function check(value, message) {
|
|
7
|
+
if (!value)
|
|
8
|
+
throw new Error(`Security contract: ${message}`);
|
|
9
|
+
passed++;
|
|
10
|
+
}
|
|
11
|
+
const suffix = `${Date.now()}-${globalThis.crypto.randomUUID()}`;
|
|
9
12
|
const secret = `contract-secret-${suffix}`;
|
|
10
13
|
const connection = { providerId: provider.id, [provider.tokenField]: secret, ...Object.fromEntries(provider.fields.filter(f => f.name !== provider.tokenField).map(f => [f.name, 'test-value'])) };
|
|
11
14
|
const ids = [];
|
|
@@ -12,7 +12,8 @@ export function createAccessTokensController({ api, permissions = [] }, _optiona
|
|
|
12
12
|
async function load(_required, _optional = {}) {
|
|
13
13
|
if (store.signal.aborted || !grants.has('security.read'))
|
|
14
14
|
return;
|
|
15
|
-
|
|
15
|
+
generation += 1;
|
|
16
|
+
const ticket = generation;
|
|
16
17
|
store.set({ patch: { loading: true } });
|
|
17
18
|
// Independent stores retain their own successful results; one unavailable store cannot hide the rest.
|
|
18
19
|
const kinds = ['publish', 'source-control', 'custom'];
|
|
@@ -105,8 +106,11 @@ export function createAccessTokensController({ api, permissions = [] }, _optiona
|
|
|
105
106
|
store.set({ patch: { draft: null, error: null } }); },
|
|
106
107
|
filter({ query, category }, _optional = {}) { store.set({ patch: { ...(query !== undefined ? { query } : {}), ...(category ? { category } : {}) } }); },
|
|
107
108
|
remove({ row }, _optional = {}) { return write({ row, remove: true }); },
|
|
108
|
-
makeDefault({ row }, _optional = {}) {
|
|
109
|
-
|
|
109
|
+
makeDefault({ row }, _optional = {}) {
|
|
110
|
+
if (row.kind === 'custom')
|
|
111
|
+
return Promise.resolve(false);
|
|
112
|
+
return write({ row, remove: false });
|
|
113
|
+
},
|
|
110
114
|
dispose(_required = {}, _optional = {}) { generation++; store.dispose(); },
|
|
111
115
|
};
|
|
112
116
|
}
|
|
@@ -21,7 +21,8 @@ export function createOtherCredentialsController({ api, permissions = [] }, _opt
|
|
|
21
21
|
async function load(_required, _optional = {}) {
|
|
22
22
|
if (store.signal.aborted || !grants.has('security.read'))
|
|
23
23
|
return;
|
|
24
|
-
|
|
24
|
+
generation += 1;
|
|
25
|
+
const ticket = generation;
|
|
25
26
|
store.set({ patch: { loading: true } });
|
|
26
27
|
await Promise.all(OTHER_STORES.map(id => read(id, ticket)));
|
|
27
28
|
if (ticket === generation)
|
|
@@ -31,7 +32,8 @@ export function createOtherCredentialsController({ api, permissions = [] }, _opt
|
|
|
31
32
|
// This is tab-local; cross-session races still require a server concurrency check.
|
|
32
33
|
let lane = Promise.resolve();
|
|
33
34
|
function write(row, remove) {
|
|
34
|
-
|
|
35
|
+
const permitted = grants.has('security.read') && grants.has(otherWritePermission({ store: row.store }));
|
|
36
|
+
if (store.signal.aborted || !permitted || (!remove && !row.supportsReplace))
|
|
35
37
|
return Promise.resolve(false);
|
|
36
38
|
const token = store.getSnapshot().drafts[`${row.store}:${row.id}`]?.trim() ?? '';
|
|
37
39
|
if (!remove && !token)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createControllerStore } from '../../core/module/controller-store.js';
|
|
2
2
|
import { ROOT_KEY_PERMISSION, START_FRESH_CONFIRMATION, isRootKeyLocked } from '../rules.js';
|
|
3
|
+
const receiverLabel = (w) => `${w.label} (${w.targetUrl})`;
|
|
3
4
|
/** Receivers named in a result need their new signing secret; the message tells the operator which ones. */
|
|
4
5
|
function resultMessage({ result, response }, _optional = {}) {
|
|
5
6
|
const hooks = response.affectedWebhooks;
|
|
6
|
-
return hooks?.length ? `${result} Update receivers: ${hooks.map(
|
|
7
|
+
return hooks?.length ? `${result} Update receivers: ${hooks.map(receiverLabel).join(', ')}` : result;
|
|
7
8
|
}
|
|
8
9
|
export function createRootKeyController({ api, permissions = [] }, _optional = {}) {
|
|
9
10
|
const granted = permissions.includes(ROOT_KEY_PERMISSION);
|
|
@@ -14,7 +15,8 @@ export function createRootKeyController({ api, permissions = [] }, _optional = {
|
|
|
14
15
|
async function load(_required, _optional = {}) {
|
|
15
16
|
if (!allowed())
|
|
16
17
|
return;
|
|
17
|
-
|
|
18
|
+
generation += 1;
|
|
19
|
+
const ticket = generation;
|
|
18
20
|
store.set({ patch: { loading: true } });
|
|
19
21
|
try {
|
|
20
22
|
const status = await api.status({}, { signal: store.signal });
|
|
@@ -66,7 +68,8 @@ export function createRootKeyController({ api, permissions = [] }, _optional = {
|
|
|
66
68
|
async preview(_required, _optional = {}) {
|
|
67
69
|
if (!canPreview())
|
|
68
70
|
return false;
|
|
69
|
-
|
|
71
|
+
generation += 1;
|
|
72
|
+
const ticket = generation;
|
|
70
73
|
store.set({ patch: { loading: true, preview: null, confirm: '', error: null } });
|
|
71
74
|
let ok = false;
|
|
72
75
|
try {
|
|
@@ -86,8 +89,12 @@ export function createRootKeyController({ api, permissions = [] }, _optional = {
|
|
|
86
89
|
}
|
|
87
90
|
return ok;
|
|
88
91
|
},
|
|
89
|
-
startFresh(_required, _optional = {}) {
|
|
90
|
-
|
|
92
|
+
startFresh(_required, _optional = {}) {
|
|
93
|
+
const state = store.getSnapshot();
|
|
94
|
+
if (!isRootKeyLocked({ status: state.status }) || !state.preview || !state.confirming || state.confirm !== START_FRESH_CONFIRMATION)
|
|
95
|
+
return Promise.resolve(false);
|
|
96
|
+
return execute(() => api.startFresh({ confirm: state.confirm }, { signal: store.signal }), 'Done. Re-enter removed credentials.');
|
|
97
|
+
},
|
|
91
98
|
cancel(_required, _optional = {}) { if (!store.getSnapshot().saving) {
|
|
92
99
|
generation++;
|
|
93
100
|
store.set({ patch: { confirming: false, preview: null, confirm: '', loading: false } });
|
|
@@ -55,9 +55,14 @@ export function useAccessTokensTab({ permissions = [] }, _optional = {}) {
|
|
|
55
55
|
reload() { void access.controller?.load({}); void other.controller?.load({}); },
|
|
56
56
|
cancelRemove() { if (!s?.saving && !o?.saving)
|
|
57
57
|
setRemoval(null); },
|
|
58
|
-
async confirmRemove() {
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
async confirmRemove() {
|
|
59
|
+
if (!removal)
|
|
60
|
+
return;
|
|
61
|
+
const ok = removal.type === 'access' ? await access.controller?.remove({ row: removal.row }) : await other.controller?.remove({ row: removal.row });
|
|
62
|
+
if (!ok)
|
|
63
|
+
throw new Error('Unable to remove credential');
|
|
64
|
+
setRemoval(null);
|
|
65
|
+
},
|
|
61
66
|
controller: access.controller, };
|
|
62
67
|
}
|
|
63
68
|
//# sourceMappingURL=AccessTokensTab.hooks.js.map
|
|
@@ -6,6 +6,10 @@ const refuse = async () => { throw new Error('Unavailable'); };
|
|
|
6
6
|
export const unavailableRootKey = { status: refuse, generate: refuse, importToken: refuse, previewStartFresh: refuse, startFresh: refuse };
|
|
7
7
|
export const noOtherCredentials = { async list() { return []; }, replace: refuse, remove: refuse };
|
|
8
8
|
export const SecurityPortsContext = createContext(null);
|
|
9
|
-
export function useSecurityPorts(_required = {}, _optional = {}) {
|
|
10
|
-
|
|
9
|
+
export function useSecurityPorts(_required = {}, _optional = {}) {
|
|
10
|
+
const ports = useContext(SecurityPortsContext);
|
|
11
|
+
if (!ports)
|
|
12
|
+
throw new AdminConfigError({ issues: ['React scope unavailable: security'] });
|
|
13
|
+
return ports;
|
|
14
|
+
}
|
|
11
15
|
//# sourceMappingURL=SecurityPorts.hooks.js.map
|
package/dist/security/rules.js
CHANGED
|
@@ -6,10 +6,22 @@ export const CATEGORIES = [
|
|
|
6
6
|
{ value: 'ai', label: 'AI' }, { value: 'ops', label: 'Ops' }, { value: 'general', label: 'General' },
|
|
7
7
|
];
|
|
8
8
|
export const OTHER_STORES = ['site-assistant', 'admin-byok', 'media-provider', 'external-mcp'];
|
|
9
|
-
export function writePermission({ kind }, _optional = {}) {
|
|
9
|
+
export function writePermission({ kind }, _optional = {}) {
|
|
10
|
+
if (kind === 'custom')
|
|
11
|
+
return 'custom-credentials.write';
|
|
12
|
+
return kind === 'publish' ? 'system.publish' : 'source-control.credentials.write';
|
|
13
|
+
}
|
|
10
14
|
export function otherWritePermission({ store }, _optional = {}) { return store === 'site-assistant' || store === 'admin-byok' ? 'admin.assistant.manage' : 'admin.integrations.manage'; }
|
|
11
|
-
export function credentialStore({ api, kind }, _optional = {}) {
|
|
12
|
-
|
|
15
|
+
export function credentialStore({ api, kind }, _optional = {}) {
|
|
16
|
+
if (kind === 'source-control')
|
|
17
|
+
return api.sourceControl;
|
|
18
|
+
return kind === 'publish' ? api.publish : api.custom;
|
|
19
|
+
}
|
|
20
|
+
function defaultCategory({ kind }, _optional = {}) {
|
|
21
|
+
if (kind === 'publish')
|
|
22
|
+
return 'hosting';
|
|
23
|
+
return kind === 'source-control' ? 'source-control' : 'general';
|
|
24
|
+
}
|
|
13
25
|
export function blankCredentialDraft({ kind, providerId = '' }, _optional = {}) {
|
|
14
26
|
return { kind, providerId, label: '', token: '', values: {}, category: defaultCategory({ kind }), baseUrl: '', additionalHosts: '', username: '' };
|
|
15
27
|
}
|
|
@@ -28,7 +40,8 @@ export function filterOtherCredentials({ rows, query, category }, _optional = {}
|
|
|
28
40
|
function findProvider({ draft, providers }, _optional = {}) { return providers.find(p => p.kind === draft.kind && p.id === draft.providerId); }
|
|
29
41
|
function isDuplicateLabel({ draft, rows }, _optional = {}) {
|
|
30
42
|
const label = draft.label.trim().toLowerCase();
|
|
31
|
-
|
|
43
|
+
const sameScope = (r) => r.kind === draft.kind && r.id !== draft.id && (draft.kind === 'custom' || r.providerId === draft.providerId);
|
|
44
|
+
return rows.some(r => sameScope(r) && r.label.trim().toLowerCase() === label);
|
|
32
45
|
}
|
|
33
46
|
function customDraftError({ draft }, _optional = {}) {
|
|
34
47
|
try {
|
|
@@ -76,7 +89,8 @@ function customPatchFields({ draft }, _optional = {}) {
|
|
|
76
89
|
export function buildCredentialPatch({ draft, providers }, _optional = {}) {
|
|
77
90
|
const token = draft.token.trim();
|
|
78
91
|
const custom = draft.kind === 'custom';
|
|
79
|
-
const
|
|
92
|
+
const connectionFor = (value) => (custom ? customConnection({ draft, token: value }) : providerConnection({ draft, token: value, providers }));
|
|
93
|
+
const connection = token ? connectionFor(token) : undefined;
|
|
80
94
|
return { label: draft.label.trim(), ...(connection ? { connection } : {}), ...(custom ? customPatchFields({ draft }) : {}) };
|
|
81
95
|
}
|
|
82
96
|
export function isRootKeyLocked({ status }, _optional = {}) { return status?.state === 'missing-with-data' || status?.state === 'mismatch'; }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { omitKeys } from '../../core/omit-keys.js';
|
|
1
2
|
const defaultSettings = () => ({ titleTemplate: '%s', defaultRobots: { noindex: false, nofollow: false }, sitemapEnabled: true, robotsRules: [] });
|
|
2
3
|
const clone = (value) => structuredClone(value);
|
|
3
4
|
/** `patch value ?? meta value`, named — {@link applySeoPatch} calls this once per field instead of
|
|
@@ -66,7 +67,7 @@ function settingOrCleared(patchValue, currentValue) {
|
|
|
66
67
|
* @complexity O(1) — three fixed field reads, no iteration. */
|
|
67
68
|
function applySettingsPatch(current, patch) {
|
|
68
69
|
const { defaultDescription, defaultOgImage, twitterSite, ...rest } = patch;
|
|
69
|
-
const
|
|
70
|
+
const retained = omitKeys(current, ['defaultDescription', 'defaultOgImage', 'twitterSite']);
|
|
70
71
|
return {
|
|
71
72
|
...retained,
|
|
72
73
|
...rest,
|
|
@@ -106,9 +107,11 @@ export function createMemorySeoApi(required = {}, { resolveEntry } = {}) {
|
|
|
106
107
|
// O(k) in patch keys; null removes only touched overrides, omission preserves other edits.
|
|
107
108
|
for (const key of Object.keys(patch)) {
|
|
108
109
|
const value = patch[key];
|
|
110
|
+
if (value === undefined)
|
|
111
|
+
continue;
|
|
109
112
|
if (value === null)
|
|
110
113
|
delete current[key];
|
|
111
|
-
else
|
|
114
|
+
else
|
|
112
115
|
Object.assign(current, { [key]: value });
|
|
113
116
|
}
|
|
114
117
|
overrides[entryId] = current;
|
|
@@ -34,7 +34,8 @@ export function createSeoEntryController({ api, entryId }, { t = key => key, des
|
|
|
34
34
|
store.set({ patch: { resolved, touched: {}, notice: t('Saved.') } });
|
|
35
35
|
// Saving finishes with the PUT, before this best-effort analysis settles. A newer save's
|
|
36
36
|
// analysis must supersede an earlier slow response (the source's 2026-09-07 race fix).
|
|
37
|
-
|
|
37
|
+
analyzeGeneration += 1;
|
|
38
|
+
const ticket = analyzeGeneration;
|
|
38
39
|
void api.analyzeSeoEntry({ entryId }).then(analysis => {
|
|
39
40
|
if (ticket === analyzeGeneration)
|
|
40
41
|
store.set({ patch: { analysis } });
|
|
@@ -6,7 +6,8 @@ export function createSitemapController({ api, enabled }, _optional = {}) {
|
|
|
6
6
|
async function load(_required = {}, _optional = {}) {
|
|
7
7
|
if (!enabled)
|
|
8
8
|
return;
|
|
9
|
-
|
|
9
|
+
generation += 1;
|
|
10
|
+
const ticket = generation;
|
|
10
11
|
store.set({ patch: { status: 'loading', error: null } });
|
|
11
12
|
try {
|
|
12
13
|
const { text } = await api.fetchSitemapXml({}, { signal: store.signal });
|
|
@@ -8,7 +8,7 @@ export function useMediaPickerBridge(required, _optional = {}) {
|
|
|
8
8
|
useEffect(() => {
|
|
9
9
|
if (!mediaPicker) {
|
|
10
10
|
setError('Media picker unavailable');
|
|
11
|
-
return;
|
|
11
|
+
return undefined;
|
|
12
12
|
}
|
|
13
13
|
const abort = new AbortController();
|
|
14
14
|
void mediaPicker.pick({ accept: callbacks.current.accept ?? ['image/*'] }, { signal: abort.signal }).then(item => {
|