@jini-ai/admin 0.6.15-rc.1 → 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.
Files changed (104) hide show
  1. package/dist/agent-plugins/adapters/memory.js +9 -3
  2. package/dist/agent-plugins/conformance/agent-plugins-api.conformance.js +1 -1
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +14 -5
  4. package/dist/agent-plugins/package-file-tree.js +4 -6
  5. package/dist/agent-plugins/react/components/AgentPluginInspector.js +5 -2
  6. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +10 -4
  7. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +8 -5
  8. package/dist/agent-plugins/rules.js +3 -1
  9. package/dist/comments/adapters/http.js +2 -1
  10. package/dist/comments/adapters/memory.js +3 -1
  11. package/dist/comments/conformance/comments-api.conformance.js +3 -2
  12. package/dist/comments/controllers/comment-queue.controller.d.ts +1 -1
  13. package/dist/comments/controllers/comment-queue.controller.js +4 -2
  14. package/dist/core/entities/rules.js +13 -20
  15. package/dist/core/module/create-admin.js +2 -1
  16. package/dist/core/omit-keys.d.ts +7 -0
  17. package/dist/core/omit-keys.js +12 -0
  18. package/dist/core/ports/forms.d.ts +5 -3
  19. package/dist/core/transport/query-suffix.d.ts +3 -0
  20. package/dist/core/transport/query-suffix.js +5 -0
  21. package/dist/forms/adapters/http.js +3 -2
  22. package/dist/forms/adapters/memory.js +3 -2
  23. package/dist/forms/controllers/field-attributes-dialog.controller.js +17 -3
  24. package/dist/forms/controllers/form-fields-editor.controller.js +1 -2
  25. package/dist/forms/controllers/form-submission-detail.controller.js +1 -2
  26. package/dist/forms/controllers/form-submissions.controller.js +1 -2
  27. package/dist/forms/controllers/forms-list.controller.js +1 -2
  28. package/dist/forms/field-settings.js +15 -4
  29. package/dist/forms/html-rules.js +10 -13
  30. package/dist/forms/react/hooks/forms-view.hooks.d.ts +2 -2
  31. package/dist/forms/react/hooks/forms-view.hooks.js +9 -1
  32. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +1 -3
  33. package/dist/forms/react/hooks/use-form-editor.hooks.js +8 -9
  34. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -4
  35. package/dist/forms/react/hooks/use-form-submissions.hooks.js +1 -5
  36. package/dist/forms/react/hooks/use-forms-list.hooks.js +1 -2
  37. package/dist/forms/rules.js +7 -2
  38. package/dist/media/adapters/memory.js +5 -3
  39. package/dist/media/controllers/library.controller.js +4 -2
  40. package/dist/media/controllers/providers.controller.js +2 -1
  41. package/dist/media/html-attributes.d.ts +1 -8
  42. package/dist/media/html-attributes.js +6 -161
  43. package/dist/media/react/components/MediaCard.js +11 -1
  44. package/dist/media/react/hooks/EditMediaPanel.hooks.js +18 -7
  45. package/dist/media/react/hooks/LibraryTab.hooks.js +3 -2
  46. package/dist/media/react/hooks/ProvidersTab.hooks.js +19 -9
  47. package/dist/media/rules.js +6 -3
  48. package/dist/playground/adapters/memory.js +26 -7
  49. package/dist/playground/conformance/playground-api.conformance.js +6 -3
  50. package/dist/playground/react/hooks/PlaygroundPorts.hooks.js +6 -2
  51. package/dist/react/components/ConfirmButton/ConfirmButton.hooks.js +1 -1
  52. package/dist/react/components/ConfirmButton/ConfirmButton.js +2 -5
  53. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +2 -2
  54. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +3 -1
  55. package/dist/react/components/RowMenu/RowMenu.hooks.js +4 -8
  56. package/dist/react/components/Sidebar.js +1 -1
  57. package/dist/react/entities/EntityField.js +1 -0
  58. package/dist/react/entities/contribution.js +2 -1
  59. package/dist/react/entities/types.js +1 -1
  60. package/dist/react/overlays.js +2 -1
  61. package/dist/react/shell/AdminShell.hooks.js +1 -1
  62. package/dist/react/shell/use-admin-shell-session.js +2 -1
  63. package/dist/react/shell/use-admin-theme.js +6 -6
  64. package/dist/redirects/adapters/memory.js +16 -13
  65. package/dist/security/adapters/http.js +10 -11
  66. package/dist/security/adapters/memory.js +50 -15
  67. package/dist/security/conformance/security-api.conformance.js +6 -3
  68. package/dist/security/controllers/access-tokens.controller.js +7 -3
  69. package/dist/security/controllers/other-credentials.controller.js +4 -2
  70. package/dist/security/controllers/root-key.controller.js +12 -5
  71. package/dist/security/react/hooks/AccessTokensTab.hooks.js +8 -3
  72. package/dist/security/react/hooks/SecurityPorts.hooks.js +6 -2
  73. package/dist/security/rules.js +19 -5
  74. package/dist/seo/adapters/memory.js +5 -2
  75. package/dist/seo/controllers/seo-entry.controller.js +2 -1
  76. package/dist/seo/controllers/sitemap.controller.js +2 -1
  77. package/dist/seo/react/hooks/MediaPickerBridge.hooks.js +1 -1
  78. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -21
  79. package/dist/seo/react/hooks/SeoPage.hooks.js +0 -8
  80. package/dist/seo/react/hooks/use-seo.hooks.js +1 -1
  81. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -1
  82. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +9 -1
  83. package/dist/seo/react/pages/SeoPage.js +17 -3
  84. package/dist/source-control/adapters/memory.js +30 -14
  85. package/dist/source-control/controllers/source-control.controller.js +5 -2
  86. package/dist/source-control/react/components/SourceControlProviderRow.js +6 -1
  87. package/dist/source-control/react/hooks/SourceControlPorts.hooks.js +6 -2
  88. package/dist/source-control/rules.js +9 -3
  89. package/dist/widgets/adapters/http.js +2 -1
  90. package/dist/widgets/adapters/memory.js +4 -2
  91. package/dist/widgets/conformance/widgets-api.conformance.js +5 -2
  92. package/dist/widgets/controllers/instance-editor.controller.js +3 -2
  93. package/dist/widgets/controllers/library.controller.js +2 -1
  94. package/dist/widgets/controllers/region-editor.controller.js +2 -1
  95. package/dist/widgets/controllers/regions.controller.js +2 -1
  96. package/dist/widgets/react/components/WidgetInstanceNotices.d.ts +1 -1
  97. package/dist/widgets/react/components/WidgetInstanceNotices.js +2 -0
  98. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +5 -2
  99. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +5 -2
  100. package/dist/widgets/react/pages/WidgetInstanceEditor.js +1 -1
  101. package/dist/widgets/react/pages/WidgetRegionEditor.js +1 -1
  102. package/dist/widgets/react/pages/WidgetsLibrary.js +1 -1
  103. package/dist/widgets/rules.js +5 -1
  104. package/package.json +13 -13
@@ -7,6 +7,7 @@ import { settleFromCache, useSharedMediaLibrary } from './MediaPage.hooks.js';
7
7
  import { useMediaPorts } from './MediaPorts.hooks.js';
8
8
  import { mediaMessagesEn } from '../../messages.en.js';
9
9
  import { canRestoreMedia } from '../../rules.js';
10
+ import { omitKeys } from '../../../core/omit-keys.js';
10
11
  const EMPTY_LABELS = {
11
12
  images: 'No images yet. Upload an image to see it here.',
12
13
  videos: 'No videos yet. Upload a video to see it here.',
@@ -52,7 +53,7 @@ function purgeDialogView({ snapshot }, _optional = {}) {
52
53
  function queryHandlers({ controller }, _optional = {}) {
53
54
  return {
54
55
  onStatus: ({ value }) => {
55
- const { status: _status, ...query } = controller?.getSnapshot().query ?? {};
56
+ const query = omitKeys(controller?.getSnapshot().query ?? {}, ['status']);
56
57
  void controller?.setQuery({ query: { ...query, ...(value === 'active' || value === 'trashed' ? { status: value } : {}) } });
57
58
  },
58
59
  onSearch: ({ value: search }) => {
@@ -183,7 +184,7 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
183
184
  const item = controller?.getSnapshot().pendingPurge;
184
185
  // The controller refuses to purge without a pending item, so there is nothing to announce.
185
186
  if (!can.canPurge || !controller || !item)
186
- return;
187
+ return undefined;
187
188
  const index = items.findIndex(row => row.id === item.id);
188
189
  return controller.confirmPurge({ confirmed: true }).then(saved => {
189
190
  if (saved) {
@@ -34,8 +34,10 @@ async function saveProviderSettings({ port, item, entry, signal }, _optional = {
34
34
  await port.saveSettings({ id: item.id, ...next }, { signal });
35
35
  }
36
36
  async function saveProvider({ port, item, signal }, { entry } = {}) {
37
- if (!entry)
38
- return removeIfStored({ port, item, signal });
37
+ if (!entry) {
38
+ await removeIfStored({ port, item, signal });
39
+ return;
40
+ }
39
41
  await saveProviderSettings({ port, item, entry, signal });
40
42
  if (entry.apiKey?.trim())
41
43
  await port.saveCredential({ id: item.id, credential: entry.apiKey }, { signal });
@@ -57,17 +59,21 @@ async function saveSequentially({ port, providers, lastRead, signal }, _optional
57
59
  function rowStatus({ entry, pending }, _optional = {}) {
58
60
  const saved = isMarkerOnlyEntry(entry);
59
61
  const mask = saved ? maskedKeyLabel(entry) : null;
62
+ const unsavedLabel = pending ? 'Unsaved' : null;
60
63
  return {
61
64
  mask,
62
- statusLabel: saved ? `Saved (${mask})` : pending ? 'Unsaved' : null,
65
+ statusLabel: saved ? `Saved (${mask})` : unsavedLabel,
63
66
  statusClassName: saved ? 'jini-field-status-badge jini-field-status-badge-success' : 'jini-field-status-badge',
64
67
  };
65
68
  }
69
+ /** Names the provider default an empty endpoint falls back to, when it has one. */
70
+ function defaultBaseUrlHint({ entry, item }) {
71
+ return !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null;
72
+ }
66
73
  function baseUrlHint({ entry, item }, _optional = {}) {
67
74
  const invalid = isProviderBaseUrlInvalid(entry);
68
75
  return {
69
- invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.'
70
- : !entry.baseUrl?.trim() && item.defaultBaseUrl ? `Uses ${item.defaultBaseUrl} by default.` : null,
76
+ invalid, hint: invalid ? 'Enter an absolute http:// or https:// URL that is not a private or internal address.' : defaultBaseUrlHint({ entry, item }),
71
77
  hintClassName: invalid ? 'jini-field-hint jini-hint-error' : 'jini-field-hint',
72
78
  hintRole: invalid ? 'alert' : undefined,
73
79
  ariaInvalid: invalid || undefined,
@@ -177,10 +183,14 @@ export function useProvidersTab(_required = {}, _optional = {}) {
177
183
  const catalog = mediaProviders?.catalog ?? discoveredCatalog;
178
184
  const pinnedIds = mediaProviders?.pinnedProviderIds ?? [];
179
185
  const ordered = sortProvidersByConfigured(catalog, editor.providers, pinnedIds);
180
- const toggleKey = (id) => setVisibleKeys(current => { const next = new Set(current); if (next.has(id))
181
- next.delete(id);
182
- else
183
- next.add(id); return next; });
186
+ const toggleKey = (id) => setVisibleKeys(current => {
187
+ const next = new Set(current);
188
+ if (next.has(id))
189
+ next.delete(id);
190
+ else
191
+ next.add(id);
192
+ return next;
193
+ });
184
194
  const rows = ordered.map((item, index) => providerRow({ item, entry: editor.providers[item.id] ?? {}, editor, visible: visibleKeys.has(item.id), toggle: () => toggleKey(item.id) }, { divider: index > 0 && !pinnedIds.includes(item.id) && pinnedIds.includes(ordered[index - 1].id), disabled: !mediaProviders }));
185
195
  return { rows, ...editorView({ editor, port: mediaProviders }) };
186
196
  }
@@ -1,3 +1,4 @@
1
+ import { trimChars } from '@jini-ai/core/text';
1
2
  /** Falls back to "all" for an absent or unrecognized ?tab= value — don't trust a raw
2
3
  * query value: a stale link or a typo must not blank the panel. */
3
4
  export function resolveActiveTab({ tabId }, _optional = {}) {
@@ -138,11 +139,13 @@ export function mediaEmbedSnippet({ slug }, _optional = {}) {
138
139
  export function mediaRowHandles({ media }, _optional = {}) {
139
140
  const used = new Set();
140
141
  return media.map((item, index) => {
141
- const slug = item.id.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
142
+ const slug = trimChars({ text: item.id.toLowerCase().replace(/[^a-z0-9]+/g, '-'), chars: '-' });
142
143
  const preferred = `media-item-${slug || index + 1}`;
143
144
  let handle = preferred, suffix = 2;
144
- while (used.has(handle))
145
- handle = `${preferred}-${suffix++}`;
145
+ while (used.has(handle)) {
146
+ handle = `${preferred}-${suffix}`;
147
+ suffix += 1;
148
+ }
146
149
  used.add(handle);
147
150
  return handle;
148
151
  });
@@ -18,13 +18,32 @@ export function createMemoryPlaygroundTargets(_required, { onListenerError } = {
18
18
  }
19
19
  return {
20
20
  getSnapshot: (_required = {}, _optional = {}) => snapshot,
21
- subscribe({ listener }, _optional = {}) { if (!disposed)
22
- listeners.add(listener); return () => listeners.delete(listener); },
23
- register({ target }, _optional = {}) { if (disposed)
24
- throw new Error('Playground target scope disposed'); const ticket = {}; owner = ticket; publish(target); return { release(_required = {}, _optional = {}) { if (owner !== ticket)
25
- return; owner = null; publish(null); } }; },
26
- dispose(_required = {}, _optional = {}) { if (disposed)
27
- return; disposed = true; owner = null; publish(null); listeners.clear(); },
21
+ subscribe({ listener }, _optional = {}) {
22
+ if (!disposed)
23
+ listeners.add(listener);
24
+ return () => listeners.delete(listener);
25
+ },
26
+ register({ target }, _optional = {}) {
27
+ if (disposed)
28
+ throw new Error('Playground target scope disposed');
29
+ const ticket = {};
30
+ owner = ticket;
31
+ publish(target);
32
+ return { release(_required = {}, _optional = {}) {
33
+ if (owner !== ticket)
34
+ return;
35
+ owner = null;
36
+ publish(null);
37
+ } };
38
+ },
39
+ dispose(_required = {}, _optional = {}) {
40
+ if (disposed)
41
+ return;
42
+ disposed = true;
43
+ owner = null;
44
+ publish(null);
45
+ listeners.clear();
46
+ },
28
47
  };
29
48
  }
30
49
  //# sourceMappingURL=memory.js.map
@@ -1,11 +1,14 @@
1
1
  /** Run with an isolated bus; this temporarily registers and releases test targets. */
2
2
  export function runPlaygroundApiConformance({ targets }, _optional = {}) {
3
3
  const checks = [];
4
- const assert = (ok, name) => { if (!ok)
5
- throw new Error(`Playground conformance: ${name}`); checks.push(name); };
4
+ const assert = (ok, name) => {
5
+ if (!ok)
6
+ throw new Error(`Playground conformance: ${name}`);
7
+ checks.push(name);
8
+ };
6
9
  const a = {}, b = {};
7
10
  let count = 0;
8
- const stop = targets.subscribe({ listener: () => count++ });
11
+ const stop = targets.subscribe({ listener: () => { count += 1; } });
9
12
  const first = targets.register({ target: a });
10
13
  let second;
11
14
  try {
@@ -1,6 +1,10 @@
1
1
  import { createContext, useContext } from 'react';
2
2
  import { AdminConfigError } from '../../../core/module/index.js';
3
3
  export const PlaygroundPortsContext = createContext(null);
4
- export function usePlaygroundPorts(_required = {}, _optional = {}) { const ports = useContext(PlaygroundPortsContext); if (!ports)
5
- throw new AdminConfigError({ issues: ['React scope unavailable: playground'] }); return ports; }
4
+ export function usePlaygroundPorts(_required = {}, _optional = {}) {
5
+ const ports = useContext(PlaygroundPortsContext);
6
+ if (!ports)
7
+ throw new AdminConfigError({ issues: ['React scope unavailable: playground'] });
8
+ return ports;
9
+ }
6
10
  //# sourceMappingURL=PlaygroundPorts.hooks.js.map
@@ -22,7 +22,7 @@ export function useConfirmButton({ onConfirm, document }, { pending, disabled }
22
22
  // standing per-row document listener.
23
23
  useEffect(() => {
24
24
  if (!confirming)
25
- return;
25
+ return undefined;
26
26
  function disarm() {
27
27
  setConfirming(false);
28
28
  }
@@ -29,11 +29,8 @@ const DEFAULT_PENDING_LABEL = '…';
29
29
  */
30
30
  export function ConfirmButton({ useConfirmButton: useConfirmButtonState = useConfirmButton, ...props }) {
31
31
  const { confirming, buttonRef, handleClick, handleBlur } = useConfirmButtonState({ onConfirm: props.onConfirm, document }, { pending: props.pending, disabled: props.disabled });
32
- const label = props.pending
33
- ? (props.pendingLabel ?? DEFAULT_PENDING_LABEL)
34
- : confirming
35
- ? props.confirmLabel
36
- : props.label;
32
+ const idleLabel = confirming ? props.confirmLabel : props.label;
33
+ const label = props.pending ? (props.pendingLabel ?? DEFAULT_PENDING_LABEL) : idleLabel;
37
34
  const className = [props.className, props.destructive ? 'btn-danger' : null].filter(Boolean).join(' ') || undefined;
38
35
  return (_jsxs(_Fragment, { children: [_jsx("button", { ref: buttonRef, type: "button", className: className, "data-armed": confirming ? 'true' : undefined, disabled: props.disabled || props.pending, onClick: handleClick, onBlur: handleBlur, "aria-label": props.ariaLabel, children: label }), _jsx("span", { className: "visually-hidden", role: "status", "aria-live": "polite", children: confirming ? `Press "${props.confirmLabel}" to confirm, or press Escape to cancel.` : '' })] }));
39
36
  }
@@ -1,4 +1,5 @@
1
1
  import { agentHandle } from '@jini-ai/agentic';
2
+ import { omitKeys } from '../../../core/omit-keys.js';
2
3
  /**
3
4
  * The agent-attribute port `ConfirmDialog` hands UI-kit's confirm controller. UI-kit names selects
4
5
  * separately; agentic publishes them as fields and still owns handle validation. Absent role/label
@@ -18,7 +19,6 @@ export const confirmDialogAgentAttrs = (required, optional = {}) => agentHandle(
18
19
  * @complexity O(k) in the number of attributes.
19
20
  */
20
21
  export function withoutAccessibleName({ attrs }, _optional = {}) {
21
- const { 'aria-label': _accessibleName, ...rest } = attrs ?? {};
22
- return rest;
22
+ return omitKeys(attrs ?? {}, ['aria-label']);
23
23
  }
24
24
  //# sourceMappingURL=confirm-dialog-attrs.js.map
@@ -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
- return count === 0 ? 'no images set' : `${count} ${count === 1 ? 'image' : 'images'}`;
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 === 'ArrowDown') {
125
- e.preventDefault();
126
- openAt(0);
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 { Fragment, createContext, createElement, useContext, useEffect, useMemo, useRef, useState, } from 'react';
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 = adminBase.replace(/\/+$/, '');
22
+ const base = trimCharsEnd({ text: adminBase, chars: '/' });
22
23
  function routePath({ entity, id, view }) {
23
24
  const root = `/${panelId}`;
24
25
  if (entity === undefined)
@@ -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(/\{([\w]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
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
@@ -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: ++sequence, render: () => render({ resolve: finish, cancel }) }],
32
+ entries: [{ id: nextSequence(), render: () => render({ resolve: finish, cancel }) }],
32
33
  },
33
34
  });
34
35
  });
@@ -14,7 +14,7 @@ export function useShellDrawer(args) {
14
14
  useEffect(() => setOpen(false), [args.routePath]);
15
15
  useEffect(() => {
16
16
  if (!open)
17
- return;
17
+ return undefined;
18
18
  const onKeyDown = (event) => {
19
19
  if (event.key === 'Escape')
20
20
  setOpen(false);
@@ -39,7 +39,8 @@ export function useAdminShellSession(args) {
39
39
  return;
40
40
  if (operation === 'logout')
41
41
  loggingOut.current = owner;
42
- const request = ++generation.current;
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
- const [mediaRevision, setMediaRevision] = useState(0);
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
- function create(input, source) {
19
- // The core port documents reference-host rejection. A supplied validator owns alternate hosts.
20
- if (validate)
21
- validate(input);
22
- else if (input.matchType === 'regex')
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 === undefined || row.status === 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
- if (validate)
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
- for (const [key, entry] of Object.entries(current)) {
47
- if (key !== id)
48
- next[key] = mediaEntry({ entry });
49
- else if (token !== undefined)
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('/media/providers', 'GET', options);
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('/media/providers', 'GET', options);
78
- await request('/media/providers', 'PUT', options, rebuildMedia(current, id, token));
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('/media/providers', 'GET', options);
86
- await request('/media/providers', 'PUT', options, rebuildMedia(current, id));
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) { if (items.some(r => r.kind === kind && r.id !== id && (kind === 'custom' || r.providerId === providerId) && r.label.toLowerCase() === label.toLowerCase()))
28
- throw new Error('Duplicate label'); }
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 = {}) { o.signal?.throwIfAborted(); if (!Object.values(input.connection).some(v => v.trim()) || !input.label.trim())
38
- throw new Error('Incomplete credential'); const providerId = kind === 'custom' ? '' : input.connection.providerId ?? ''; check(input.label, providerId); return insert(project({ id: `credential-${++sequence}`, patch: input, providerId, label: input.label })); },
39
- async update({ id, patch }, o = {}) { o.signal?.throwIfAborted(); const old = items.find(r => r.id === id && r.kind === kind); if (!old)
40
- throw new Error('Credential not found'); const label = patch.label ?? old.label; check(label, old.providerId, id); return insert(project({ id, patch, providerId: old.providerId, label }, { initial: old })); },
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 = {}) { o.signal?.throwIfAborted(); const row = items.find(r => r.store === store && r.id === id); if (!row?.supportsReplace || !token.trim())
55
- throw new Error('Replacement unavailable'); items = items.map(r => r === row ? Object.freeze({ ...r, configured: true, updatedAt: new Date().toISOString() }) : r); },
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 = {}) { o.signal?.throwIfAborted(); if (current.active)
65
- return { ...activate('already-active') }; if (current.state !== 'missing')
66
- throw new Error('Key dependent data'); return activate('created'); },
67
- async importToken({ token }, o = {}) { o.signal?.throwIfAborted(); if (!/^[0-9a-f]{64}$/i.test(token))
68
- throw new Error('Invalid token'); return { ...activate('unlocked'), resealed: 0 }; },
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 = {}) { o.signal?.throwIfAborted(); if (confirm !== START_FRESH_CONFIRMATION)
71
- throw new Error('Confirmation required'); return { ...activate('started-fresh'), discarded: preview.removes, kept: 0, affectedWebhooks: preview.affectedWebhooks, restorePointId: 'memory-restore-point' }; },
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) { if (!value)
7
- throw new Error(`Security contract: ${message}`); passed++; }
8
- const suffix = `${Date.now()}-${Math.random()}`;
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
- const ticket = ++generation;
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 = {}) { if (row.kind === 'custom')
109
- return Promise.resolve(false); return write({ row, remove: false }); },
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
  }