@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.
Files changed (99) 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/react/hooks/forms-view.hooks.d.ts +2 -2
  30. package/dist/forms/react/hooks/forms-view.hooks.js +9 -1
  31. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +1 -3
  32. package/dist/forms/react/hooks/use-form-editor.hooks.js +8 -9
  33. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -4
  34. package/dist/forms/react/hooks/use-form-submissions.hooks.js +1 -5
  35. package/dist/forms/react/hooks/use-forms-list.hooks.js +1 -2
  36. package/dist/forms/rules.js +7 -2
  37. package/dist/media/adapters/memory.js +5 -3
  38. package/dist/media/controllers/library.controller.js +4 -2
  39. package/dist/media/controllers/providers.controller.js +2 -1
  40. package/dist/media/react/components/MediaCard.js +11 -1
  41. package/dist/media/react/hooks/EditMediaPanel.hooks.js +18 -7
  42. package/dist/media/react/hooks/LibraryTab.hooks.js +3 -2
  43. package/dist/media/react/hooks/ProvidersTab.hooks.js +19 -9
  44. package/dist/media/rules.js +6 -3
  45. package/dist/playground/adapters/memory.js +26 -7
  46. package/dist/playground/conformance/playground-api.conformance.js +6 -3
  47. package/dist/playground/react/hooks/PlaygroundPorts.hooks.js +6 -2
  48. package/dist/react/components/ConfirmButton/ConfirmButton.hooks.js +1 -1
  49. package/dist/react/components/ConfirmButton/ConfirmButton.js +2 -5
  50. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +2 -2
  51. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +3 -1
  52. package/dist/react/components/RowMenu/RowMenu.hooks.js +4 -8
  53. package/dist/react/components/Sidebar.js +1 -1
  54. package/dist/react/entities/EntityField.js +1 -0
  55. package/dist/react/entities/contribution.js +2 -1
  56. package/dist/react/entities/types.js +1 -1
  57. package/dist/react/overlays.js +2 -1
  58. package/dist/react/shell/AdminShell.hooks.js +1 -1
  59. package/dist/react/shell/use-admin-shell-session.js +2 -1
  60. package/dist/react/shell/use-admin-theme.js +6 -6
  61. package/dist/redirects/adapters/memory.js +16 -13
  62. package/dist/security/adapters/http.js +10 -11
  63. package/dist/security/adapters/memory.js +50 -15
  64. package/dist/security/conformance/security-api.conformance.js +6 -3
  65. package/dist/security/controllers/access-tokens.controller.js +7 -3
  66. package/dist/security/controllers/other-credentials.controller.js +4 -2
  67. package/dist/security/controllers/root-key.controller.js +12 -5
  68. package/dist/security/react/hooks/AccessTokensTab.hooks.js +8 -3
  69. package/dist/security/react/hooks/SecurityPorts.hooks.js +6 -2
  70. package/dist/security/rules.js +19 -5
  71. package/dist/seo/adapters/memory.js +5 -2
  72. package/dist/seo/controllers/seo-entry.controller.js +2 -1
  73. package/dist/seo/controllers/sitemap.controller.js +2 -1
  74. package/dist/seo/react/hooks/MediaPickerBridge.hooks.js +1 -1
  75. package/dist/seo/react/hooks/use-seo.hooks.js +1 -1
  76. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +1 -1
  77. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +9 -1
  78. package/dist/seo/react/pages/SeoPage.js +7 -1
  79. package/dist/source-control/adapters/memory.js +30 -14
  80. package/dist/source-control/controllers/source-control.controller.js +5 -2
  81. package/dist/source-control/react/components/SourceControlProviderRow.js +6 -1
  82. package/dist/source-control/react/hooks/SourceControlPorts.hooks.js +6 -2
  83. package/dist/source-control/rules.js +9 -3
  84. package/dist/widgets/adapters/http.js +2 -1
  85. package/dist/widgets/adapters/memory.js +4 -2
  86. package/dist/widgets/conformance/widgets-api.conformance.js +5 -2
  87. package/dist/widgets/controllers/instance-editor.controller.js +3 -2
  88. package/dist/widgets/controllers/library.controller.js +2 -1
  89. package/dist/widgets/controllers/region-editor.controller.js +2 -1
  90. package/dist/widgets/controllers/regions.controller.js +2 -1
  91. package/dist/widgets/react/components/WidgetInstanceNotices.d.ts +1 -1
  92. package/dist/widgets/react/components/WidgetInstanceNotices.js +2 -0
  93. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +5 -2
  94. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +5 -2
  95. package/dist/widgets/react/pages/WidgetInstanceEditor.js +1 -1
  96. package/dist/widgets/react/pages/WidgetRegionEditor.js +1 -1
  97. package/dist/widgets/react/pages/WidgetsLibrary.js +1 -1
  98. package/dist/widgets/rules.js +5 -1
  99. 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
- 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
  }
@@ -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
- const ticket = ++generation;
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
- if (store.signal.aborted || !grants.has('security.read') || !grants.has(otherWritePermission({ store: row.store })) || (!remove && !row.supportsReplace))
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(w => `${w.label} (${w.targetUrl})`).join(', ')}` : result;
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
- const ticket = ++generation;
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
- const ticket = ++generation;
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 = {}) { const state = store.getSnapshot(); if (!isRootKeyLocked({ status: state.status }) || !state.preview || !state.confirming || state.confirm !== START_FRESH_CONFIRMATION)
90
- return Promise.resolve(false); return execute(() => api.startFresh({ confirm: state.confirm }, { signal: store.signal }), 'Done. Re-enter removed credentials.'); },
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() { if (!removal)
59
- return; const ok = removal.type === 'access' ? await access.controller?.remove({ row: removal.row }) : await other.controller?.remove({ row: removal.row }); if (!ok)
60
- throw new Error('Unable to remove credential'); setRemoval(null); },
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 = {}) { const ports = useContext(SecurityPortsContext); if (!ports)
10
- throw new AdminConfigError({ issues: ['React scope unavailable: security'] }); return ports; }
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
@@ -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 = {}) { return kind === 'custom' ? 'custom-credentials.write' : kind === 'publish' ? 'system.publish' : 'source-control.credentials.write'; }
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 = {}) { return kind === 'source-control' ? api.sourceControl : kind === 'publish' ? api.publish : api.custom; }
12
- function defaultCategory({ kind }, _optional = {}) { return kind === 'publish' ? 'hosting' : kind === 'source-control' ? 'source-control' : 'general'; }
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
- return rows.some(r => r.kind === draft.kind && r.id !== draft.id && (draft.kind === 'custom' || r.providerId === draft.providerId) && r.label.trim().toLowerCase() === label);
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 connection = token ? (custom ? customConnection({ draft, token }) : providerConnection({ draft, token, providers })) : undefined;
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 { defaultDescription: _description, defaultOgImage: _image, twitterSite: _site, ...retained } = current;
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 if (value !== undefined)
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
- const ticket = ++analyzeGeneration;
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
- const ticket = ++generation;
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: !enabled ? 'disabled' : cached.query.error ? 'error' : cached.query.data ? 'ready' : 'loading', error: cached.query.error?.message ?? null, xmlText: cached.query.data?.text ?? '' };
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 === "error" ? "failure" : issue.severity === "warning" ? "warning" : "success"}`, 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}`))) }))] }));
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