@jini-ai/admin 0.6.15-rc.2 → 0.6.15
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/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 +2 -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 +13 -13
|
@@ -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 => {
|
|
@@ -27,7 +27,7 @@ export function useSeo({ api }, { t, events } = {}) {
|
|
|
27
27
|
}, [controller, cached.query.data]);
|
|
28
28
|
useEffect(() => {
|
|
29
29
|
if (!controller || !events)
|
|
30
|
-
return;
|
|
30
|
+
return undefined;
|
|
31
31
|
return events.subscribe({ onRefresh: () => { invalidate({ key: SEO_KEYS.settings }); } });
|
|
32
32
|
}, [controller, events, invalidate]);
|
|
33
33
|
return {
|
|
@@ -18,7 +18,7 @@ export function useShareImageWarning({ value, previewUrl, api }, { t = key => ke
|
|
|
18
18
|
useEffect(() => {
|
|
19
19
|
const inspect = api.inspectShareImage;
|
|
20
20
|
if (!previewUrl || !inspect || !isBrowserSameOrigin({ url: previewUrl }))
|
|
21
|
-
return;
|
|
21
|
+
return undefined;
|
|
22
22
|
const controller = new AbortController();
|
|
23
23
|
let active = true;
|
|
24
24
|
const timeout = setTimeout(() => {
|
|
@@ -4,10 +4,18 @@ import { useController } from '../../../core/react/use-controller.js';
|
|
|
4
4
|
import { createSitemapController } from '../../controllers/sitemap.controller.js';
|
|
5
5
|
import { parseSitemapXml, filterSitemapEntries } from '../../rules.js';
|
|
6
6
|
import { useSeoPorts } from './SeoPorts.hooks.js';
|
|
7
|
+
/** Disabled outranks a failed fetch, which outranks a loaded document; anything else is still loading. */
|
|
8
|
+
function sitemapStatus({ enabled, query }) {
|
|
9
|
+
if (!enabled)
|
|
10
|
+
return 'disabled';
|
|
11
|
+
if (query.error)
|
|
12
|
+
return 'error';
|
|
13
|
+
return query.data ? 'ready' : 'loading';
|
|
14
|
+
}
|
|
7
15
|
export function useSitemapModal({ api, enabled }, _optional = {}) {
|
|
8
16
|
const cached = useSitemapQuery({ api, enabled });
|
|
9
17
|
const { controller, snapshot } = useController({ create: () => createSitemapController({ api: cached.api, enabled }), dependencies: [cached.api, enabled] }, { start: ({ controller }) => { void controller.load({}); } });
|
|
10
|
-
const state = { ...(snapshot ?? { filter: '', view: 'table' }), status:
|
|
18
|
+
const state = { ...(snapshot ?? { filter: '', view: 'table' }), status: sitemapStatus({ enabled, query: cached.query }), error: cached.query.error?.message ?? null, xmlText: cached.query.data?.text ?? '' };
|
|
11
19
|
// Parse only when text/status changes, never on each filter keystroke.
|
|
12
20
|
const entries = useMemo(() => state.status === 'ready' ? parseSitemapXml({ xmlText: state.xmlText }) : [], [state.status, state.xmlText]);
|
|
13
21
|
const filteredEntries = useMemo(() => filterSitemapEntries({ entries, query: state.filter }), [entries, state.filter]);
|
|
@@ -52,6 +52,12 @@ function EntryPicker({ locale, entryId, onChange, useEntryPickerHook = useWiredE
|
|
|
52
52
|
than leaving a placeholder-only control with no name at all. The i18n key is unchanged. */
|
|
53
53
|
return (_jsx("div", { className: "jini-seo-entry-picker", children: _jsx(Select, { value: entryId, onChange: onChange, options: options ?? [], placeholder: t({ locale: locale, key: "Choose an entry…" }), "aria-label": t({ locale: locale, key: "Entry" }), agentHandle: "seo-entry-picker", translate: (key) => t({ locale, key }) }) }));
|
|
54
54
|
}
|
|
55
|
+
/** Status-chip modifier for an issue: errors read as failures, warnings as warnings, anything else as success. */
|
|
56
|
+
function issueStatusClass(severity) {
|
|
57
|
+
if (severity === "error")
|
|
58
|
+
return "failure";
|
|
59
|
+
return severity === "warning" ? "warning" : "success";
|
|
60
|
+
}
|
|
55
61
|
/** Read-only score + issues view (REQ-07) — exact field names read off `SeoAnalysis`/`SeoIssue`
|
|
56
62
|
* (`src/seo/types.ts`), not guessed. No state of its own — only the severity sort, which lives in
|
|
57
63
|
* `rules.ts` as `sortIssuesBySeverity`. */
|
|
@@ -62,7 +68,7 @@ function AnalyzePanel(props) {
|
|
|
62
68
|
/* Heading + score pill on one line, issues as a compact list (owner, 2026-10-15: the bare
|
|
63
69
|
"Score: 100 / No issues." read as unstyled text). The pill reuses the issue pills' status
|
|
64
70
|
tones, so the score and the issues under it speak one colour language. */
|
|
65
|
-
return (_jsxs("section", { className: "jini-seo-analyze-panel", "aria-labelledby": "seo-analysis-heading", children: [_jsxs("div", { className: "jini-seo-analyze-head", children: [_jsx("h3", { id: "seo-analysis-heading", children: t({ locale: locale, key: "Analysis" }) }), _jsxs("span", { className: `jini-status jini-status-${tone} jini-seo-score`, children: [t({ locale: locale, key: "Score:" }), " ", _jsx("strong", { children: props.analysis.score })] })] }), sortedIssues.length === 0 ? (_jsx("p", { className: "jini-seo-analyze-empty", children: t({ locale: locale, key: "No issues." }) })) : (_jsx("ul", { className: "jini-seo-issues", children: sortedIssues.map((issue, i) => (_jsxs("li", { className: "jini-seo-issue", children: [_jsx("span", { className: `jini-status jini-status-${issue.severity
|
|
71
|
+
return (_jsxs("section", { className: "jini-seo-analyze-panel", "aria-labelledby": "seo-analysis-heading", children: [_jsxs("div", { className: "jini-seo-analyze-head", children: [_jsx("h3", { id: "seo-analysis-heading", children: t({ locale: locale, key: "Analysis" }) }), _jsxs("span", { className: `jini-status jini-status-${tone} jini-seo-score`, children: [t({ locale: locale, key: "Score:" }), " ", _jsx("strong", { children: props.analysis.score })] })] }), sortedIssues.length === 0 ? (_jsx("p", { className: "jini-seo-analyze-empty", children: t({ locale: locale, key: "No issues." }) })) : (_jsx("ul", { className: "jini-seo-issues", children: sortedIssues.map((issue, i) => (_jsxs("li", { className: "jini-seo-issue", children: [_jsx("span", { className: `jini-status jini-status-${issueStatusClass(issue.severity)}`, children: issue.severity }), _jsxs("span", { className: "jini-seo-issue-text", children: [issue.message, issue.field ? _jsxs("span", { className: "jini-muted-cell", children: [" (", issue.field, ")"] }) : null] })] }, `${issue.code}-${i}`))) }))] }));
|
|
66
72
|
}
|
|
67
73
|
/** Per-entry overrides edit form (REQ-06). Pre-fills from `getSeoEntry`'s resolved meta, but
|
|
68
74
|
* tracks which fields the user actually touched so `putSeoEntry` only ever sends a genuine
|