@jini-ai/admin 0.4.12 → 0.6.10

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 (151) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
  3. package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
  4. package/dist/agent-plugins/package-file-tree.js +54 -51
  5. package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
  6. package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
  7. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
  8. package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
  9. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
  10. package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
  11. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
  12. package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
  13. package/dist/browser/navigation.js +44 -27
  14. package/dist/comments/adapters/memory.js +30 -12
  15. package/dist/comments/conformance/comments-api.conformance.js +55 -33
  16. package/dist/comments/controllers/comment-queue.controller.js +13 -4
  17. package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
  18. package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
  19. package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
  20. package/dist/comments/react/index.d.ts +2 -1
  21. package/dist/comments/react/index.js +1 -1
  22. package/dist/consent/react/ConsentFields.d.ts +35 -0
  23. package/dist/consent/react/ConsentFields.js +76 -0
  24. package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
  25. package/dist/consent/react/ConsentPreviewFrame.js +14 -0
  26. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
  27. package/dist/consent/react/ConsentSettingsPanel.js +6 -17
  28. package/dist/consent/react/index.d.ts +6 -1
  29. package/dist/consent/react/index.js +2 -0
  30. package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
  31. package/dist/consent/react/settings-panel.hooks.js +26 -0
  32. package/dist/core/entities/rules.js +126 -81
  33. package/dist/core/manifest/rules.js +10 -10
  34. package/dist/core/module/create-admin.js +195 -117
  35. package/dist/core/ports/redirects.d.ts +8 -6
  36. package/dist/core/ports/redirects.js +8 -6
  37. package/dist/core/routing/rules.js +9 -4
  38. package/dist/core/transport/http.js +37 -20
  39. package/dist/forms/adapters/memory.js +22 -11
  40. package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
  41. package/dist/forms/conformance/forms-api.conformance.js +42 -46
  42. package/dist/forms/field-settings.js +150 -115
  43. package/dist/forms/html-rules.js +45 -28
  44. package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
  45. package/dist/forms/react/components/FieldSettingsRows.js +29 -0
  46. package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
  47. package/dist/forms/react/components/FieldSettingsSection.js +5 -6
  48. package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
  49. package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
  50. package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
  51. package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
  52. package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
  53. package/dist/forms/react/components/FormSubmissions.js +4 -20
  54. package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
  55. package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
  56. package/dist/media/adapters/memory.js +31 -26
  57. package/dist/media/controllers/edit-media.controller.js +2 -1
  58. package/dist/media/controllers/library.controller.js +48 -35
  59. package/dist/media/controllers/providers.controller.js +2 -1
  60. package/dist/media/html-attributes.js +29 -15
  61. package/dist/media/index.d.ts +1 -1
  62. package/dist/media/index.js +1 -1
  63. package/dist/media/react/components/MediaIcons.d.ts +0 -3
  64. package/dist/media/react/components/MediaIcons.js +0 -9
  65. package/dist/media/react/components/MediaLightbox.d.ts +2 -1
  66. package/dist/media/react/components/MediaLightbox.js +11 -5
  67. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
  68. package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
  69. package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
  70. package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
  71. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
  72. package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
  73. package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
  74. package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
  75. package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
  76. package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
  77. package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
  78. package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
  79. package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
  80. package/dist/media/react/index.js +4 -0
  81. package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
  82. package/dist/media/react/tabs/LibraryTab.js +2 -2
  83. package/dist/media/rules.d.ts +7 -0
  84. package/dist/media/rules.js +25 -2
  85. package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
  86. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
  87. package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
  88. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
  89. package/dist/react/components/RowMenu/RowMenu.js +16 -10
  90. package/dist/react/components/Sidebar.js +32 -26
  91. package/dist/react/entities/EntityEdit.js +17 -14
  92. package/dist/react/entities/EntityField.d.ts +5 -2
  93. package/dist/react/entities/EntityField.js +38 -23
  94. package/dist/react/entities/EntityList.js +6 -5
  95. package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
  96. package/dist/react/entities/RelationTruncationNotice.js +9 -0
  97. package/dist/react/entities/use-entity-edit.d.ts +4 -2
  98. package/dist/react/entities/use-entity-edit.js +52 -33
  99. package/dist/react/shell/AdminShell.js +40 -23
  100. package/dist/react/shell/model.d.ts +4 -1
  101. package/dist/react/shell/model.js +3 -2
  102. package/dist/react/shell/use-admin-shell-session.js +25 -26
  103. package/dist/redirects/adapters/memory.js +9 -2
  104. package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
  105. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
  106. package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
  107. package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
  108. package/dist/security/adapters/http.js +25 -10
  109. package/dist/security/adapters/memory.js +19 -5
  110. package/dist/security/controllers/access-tokens.controller.js +27 -26
  111. package/dist/security/controllers/other-credentials.controller.js +4 -2
  112. package/dist/security/controllers/root-key.controller.js +17 -9
  113. package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
  114. package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
  115. package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
  116. package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
  117. package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
  118. package/dist/security/react/tabs/RootKeyTab.js +12 -3
  119. package/dist/security/rules.js +45 -25
  120. package/dist/seo/controllers/seo.controller.js +4 -2
  121. package/dist/seo/react/components/MediaRefField.d.ts +1 -1
  122. package/dist/seo/react/components/MediaRefField.js +19 -2
  123. package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
  124. package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
  125. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
  126. package/dist/seo/react/pages/SeoPage.d.ts +4 -4
  127. package/dist/seo/react/pages/SeoPage.js +26 -6
  128. package/dist/seo/rules.js +39 -10
  129. package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
  130. package/dist/source-control/controllers/source-control.controller.js +20 -8
  131. package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
  132. package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
  133. package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
  134. package/dist/widgets/controllers/instance-editor.controller.js +33 -24
  135. package/dist/widgets/controllers/library.controller.js +18 -12
  136. package/dist/widgets/controllers/region-editor.controller.js +6 -5
  137. package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
  138. package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
  139. package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
  140. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
  141. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
  142. package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
  143. package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
  144. package/dist/widgets/rules.d.ts +14 -2
  145. package/dist/widgets/rules.js +9 -6
  146. package/package.json +23 -14
  147. package/src/themes/default/admin.css +51 -0
  148. package/src/themes/default/chat.css +21 -0
  149. package/src/themes/default/manifest.json +29 -0
  150. package/dist/media/styles.d.ts +0 -4
  151. package/dist/media/styles.js +0 -5
@@ -1,6 +1,48 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { draftForRow, invalidDraftFields, missingRequiredFields } from './rules.js';
3
3
  import { loadEntityRow, useEntityRead } from './use-entity-read.js';
4
+ /** The first form for a loaded row, or null while it is loading or an edited row was not found. */
5
+ function initialForm({ descriptor, loaded, id }) {
6
+ if (!loaded || (id !== null && loaded.row === null))
7
+ return null;
8
+ const draft = draftForRow({ descriptor, row: loaded.row });
9
+ const jsonText = Object.create(null);
10
+ for (const field of descriptor.fields) {
11
+ if (field.kind === 'json')
12
+ jsonText[field.name] = draft[field.name] === undefined ? '' : JSON.stringify(draft[field.name], null, 2);
13
+ }
14
+ return { draft, jsonText, jsonErrors: {} };
15
+ }
16
+ function canSaveForm({ form, missing, invalid, pending }) {
17
+ if (form === null || pending !== null)
18
+ return false;
19
+ return missing.length === 0 && invalid.length === 0 && !Object.values(form.jsonErrors).some(Boolean);
20
+ }
21
+ function parseJsonText(text) {
22
+ try {
23
+ return { value: text.trim() === '' ? undefined : JSON.parse(text), failed: false };
24
+ }
25
+ catch {
26
+ return { value: undefined, failed: true };
27
+ }
28
+ }
29
+ function mutationAllowed({ kind, canSave, id, port }) {
30
+ if (kind === 'save')
31
+ return canSave;
32
+ return id !== null && port.remove !== undefined;
33
+ }
34
+ /** Run the port call; resolves to the saved row's id, or null after a removal. */
35
+ async function performMutation({ kind, port, id, draft }) {
36
+ if (kind === 'remove' && id !== null && port.remove) {
37
+ await port.remove(id);
38
+ return null;
39
+ }
40
+ // A create omits blanks; an update carries explicit undefined to clear optional fields.
41
+ const saved = id === null
42
+ ? await port.create(Object.fromEntries(Object.entries(draft).filter(([, value]) => value !== undefined)))
43
+ : await port.update(id, draft);
44
+ return saved.id;
45
+ }
4
46
  /** Called by the editor mounted for one entity/id pair. */
5
47
  export function useEntityEdit({ port, ...props }) {
6
48
  const { entityName, id, registry, routes } = props;
@@ -15,22 +57,16 @@ export function useEntityEdit({ port, ...props }) {
15
57
  const [mutation, setMutation] = useState({ scope, kind: null, failed: false });
16
58
  const busy = useRef(false);
17
59
  let form = formState?.scope === scope ? formState.form : null;
18
- if (read.data && form === null && (id === null || read.data.row !== null)) {
19
- const draft = draftForRow({ descriptor: port.descriptor, row: read.data.row });
20
- const jsonText = Object.create(null);
21
- for (const field of port.descriptor.fields) {
22
- if (field.kind === 'json')
23
- jsonText[field.name] = draft[field.name] === undefined ? '' : JSON.stringify(draft[field.name], null, 2);
24
- }
25
- form = { draft, jsonText, jsonErrors: {} };
26
- setFormState({ scope, form });
60
+ if (form === null) {
61
+ form = initialForm({ descriptor: port.descriptor, loaded: read.data, id });
62
+ if (form !== null)
63
+ setFormState({ scope, form });
27
64
  }
28
65
  const draft = form?.draft ?? {};
29
66
  const missing = missingRequiredFields({ descriptor: port.descriptor, draft });
30
67
  const invalid = invalidDraftFields({ descriptor: port.descriptor, draft });
31
68
  const pending = mutation.scope === scope ? mutation.kind : null;
32
- const canSave = form !== null && missing.length === 0 && invalid.length === 0 &&
33
- !Object.values(form.jsonErrors).some(Boolean) && pending === null;
69
+ const canSave = canSaveForm({ form, missing, invalid, pending });
34
70
  function setField({ name, value }) {
35
71
  if (!port.descriptor.fields.some((field) => field.name === name) || busy.current)
36
72
  return;
@@ -39,14 +75,7 @@ export function useEntityEdit({ port, ...props }) {
39
75
  function setJsonText({ name, text }) {
40
76
  if (!port.descriptor.fields.some((field) => field.name === name && field.kind === 'json') || busy.current)
41
77
  return;
42
- let value;
43
- let failed = false;
44
- try {
45
- value = text.trim() === '' ? undefined : JSON.parse(text);
46
- }
47
- catch {
48
- failed = true;
49
- }
78
+ const { value, failed } = parseJsonText(text);
50
79
  setFormState((state) => state?.scope === scope ? {
51
80
  scope,
52
81
  form: {
@@ -57,25 +86,15 @@ export function useEntityEdit({ port, ...props }) {
57
86
  } : state);
58
87
  }
59
88
  async function mutate({ kind }) {
60
- if (busy.current || (kind === 'save' && !canSave) || (kind === 'remove' && (id === null || port.remove === undefined)))
89
+ if (busy.current || !mutationAllowed({ kind, canSave, id, port }))
61
90
  return;
62
91
  busy.current = true;
63
92
  setMutation({ scope, kind, failed: false });
64
93
  const active = () => mounted.current && currentScope.current === scope;
65
94
  try {
66
- if (kind === 'remove' && id !== null && port.remove) {
67
- await port.remove(id);
68
- if (active())
69
- routes.navigate({ entity: entityName });
70
- }
71
- else {
72
- // A create omits blanks; an update carries explicit undefined to clear optional fields.
73
- const saved = id === null
74
- ? await port.create(Object.fromEntries(Object.entries(draft).filter(([, value]) => value !== undefined)))
75
- : await port.update(id, draft);
76
- if (active())
77
- routes.navigate({ entity: entityName, id: saved.id });
78
- }
95
+ const savedId = await performMutation({ kind, port, id, draft });
96
+ if (active())
97
+ routes.navigate(savedId === null ? { entity: entityName } : { entity: entityName, id: savedId });
79
98
  if (active())
80
99
  setMutation({ scope, kind: null, failed: false });
81
100
  }
@@ -5,6 +5,40 @@ import { useShellDrawer, useShellRoute } from './AdminShell.hooks.js';
5
5
  import { resolveAdminShellModel } from './model.js';
6
6
  import { useAdminShellSession } from './use-admin-shell-session.js';
7
7
  import { useAdminTheme } from './use-admin-theme.js';
8
+ /** Copy only the theme props the host supplied, so absent ones keep the hook's own defaults. */
9
+ function themeOptions(props) {
10
+ return {
11
+ ...(props.theme ? { theme: props.theme } : {}),
12
+ ...(props.colorScheme ? { colorScheme: props.colorScheme } : {}),
13
+ ...(props.themeEnvironment ? { environment: props.themeEnvironment } : {}),
14
+ ...(props.themePreferenceStore ? { preferenceStore: props.themePreferenceStore } : {}),
15
+ ...(props.onColorSchemeChange ? { onColorSchemeChange: props.onColorSchemeChange } : {}),
16
+ };
17
+ }
18
+ /** Loading, login or session-error UI for every state except an authenticated one. */
19
+ function renderSessionGate({ props, context, state, refresh }) {
20
+ if (state.status === 'checking')
21
+ return _jsx("div", { className: "boot-screen", role: "status", children: props.labels.loading });
22
+ if (state.status === 'anonymous')
23
+ return props.slots.login({ context, refreshSession: refresh });
24
+ if (props.slots.sessionError)
25
+ return props.slots.sessionError({ error: state.error, retry: refresh });
26
+ return _jsxs("div", { role: "alert", children: [_jsx("p", { children: props.labels.sessionError }), _jsx("button", { type: "button", onClick: () => { void refresh(); }, children: props.labels.retry })] });
27
+ }
28
+ /** Resolve the authenticated route, nav and slot arguments from the session's contributions. */
29
+ function resolveShellView({ props, context, session, routePath, controller }, { appearance }) {
30
+ const model = resolveAdminShellModel({ panels: props.panels, session, routePath, defaultPanelId: props.defaultPanelId }, { capabilities: props.capabilities ?? [] });
31
+ const slotArgs = {
32
+ context, session, route: model.route, logout: controller.logout, refreshSession: controller.refresh,
33
+ ...(appearance ? { appearance } : {}),
34
+ };
35
+ const nav = props.translateNav ? props.translateNav({ groups: model.nav }) : model.nav;
36
+ return { model, slotArgs, nav };
37
+ }
38
+ /** The manifest-driven sidebar: header slot, nav groups, rail toggle and footer slot. */
39
+ function ShellSidebar({ props, view, open, id }) {
40
+ return (_jsxs(Sidebar, { activeId: view.model.route.panelId, base: props.adminBase, open: open, id: id, label: props.labels.navigation, railStorageKey: props.railStorageKey, railDefaultCollapsed: props.railDefaultCollapsed ?? false, children: [props.slots.sidebarHeader, _jsx(Sidebar.Nav, { groups: view.nav, soonLabel: props.labels.soon, collapsibleGroups: props.collapsibleGroups ?? [] }), _jsxs(Sidebar.Footer, { children: [_jsx(Sidebar.RailToggle, { expandLabel: props.labels.expandSidebar, collapseLabel: props.labels.collapseSidebar }), props.slots.sidebarFooter?.(view.slotArgs)] })] }));
41
+ }
8
42
  /**
9
43
  * A host-composed admin layout with manifest navigation and a session gate.
10
44
  * @param props Required host scope, ports, contributions, labels and slots; optional layout settings.
@@ -13,34 +47,17 @@ import { useAdminTheme } from './use-admin-theme.js';
13
47
  export function AdminShell(props) {
14
48
  const context = useMemo(() => ({ apiBase: props.apiBase, workspace: props.workspace }), [props.apiBase, props.workspace]);
15
49
  const session = useAdminShellSession({ port: props.session, context });
16
- const appearance = useAdminTheme({ userId: session.state.status === 'authenticated' ? session.state.session.user.id : null, workspace: context.workspace }, {
17
- ...(props.theme ? { theme: props.theme } : {}),
18
- ...(props.colorScheme ? { colorScheme: props.colorScheme } : {}),
19
- ...(props.themeEnvironment ? { environment: props.themeEnvironment } : {}),
20
- ...(props.themePreferenceStore ? { preferenceStore: props.themePreferenceStore } : {}),
21
- ...(props.onColorSchemeChange ? { onColorSchemeChange: props.onColorSchemeChange } : {}),
22
- });
50
+ const appearance = useAdminTheme({ userId: session.state.status === 'authenticated' ? session.state.session.user.id : null, workspace: context.workspace }, themeOptions(props));
23
51
  const routePath = useShellRoute({ navigation: props.navigation, base: props.adminBase });
24
52
  const drawer = useShellDrawer({ routePath });
25
53
  const id = useId();
26
54
  const sidebarId = `${id}-sidebar`;
27
55
  const mainId = `${id}-main`;
28
- if (session.state.status === 'checking')
29
- return _jsx("div", { className: "boot-screen", role: "status", children: props.labels.loading });
30
- if (session.state.status === 'anonymous')
31
- return props.slots.login({ context, refreshSession: session.refresh });
32
- if (session.state.status === 'error') {
33
- if (props.slots.sessionError)
34
- return props.slots.sessionError({ error: session.state.error, retry: session.refresh });
35
- return _jsxs("div", { role: "alert", children: [_jsx("p", { children: props.labels.sessionError }), _jsx("button", { type: "button", onClick: () => { void session.refresh(); }, children: props.labels.retry })] });
36
- }
37
- const model = resolveAdminShellModel({ panels: props.panels, session: session.state.session, routePath, defaultPanelId: props.defaultPanelId }, { capabilities: props.capabilities ?? [] });
38
- const slotArgs = {
39
- context, session: session.state.session, route: model.route, logout: session.logout, refreshSession: session.refresh,
40
- ...(appearance ? { appearance } : {}),
41
- };
42
- const nav = props.translateNav ? props.translateNav({ groups: model.nav }) : model.nav;
56
+ if (session.state.status !== 'authenticated')
57
+ return renderSessionGate({ props, context, state: session.state, refresh: session.refresh });
58
+ const view = resolveShellView({ props, context, session: session.state.session, routePath, controller: session }, { appearance });
59
+ const { model, slotArgs } = view;
43
60
  const assistant = props.slots.assistant;
44
- return (_jsxs("div", { className: ['admin-layout', props.className].filter(Boolean).join(' '), children: [_jsx("a", { href: `#${mainId}`, className: "skip-link", children: props.labels.skipToContent }), _jsxs(Sidebar, { activeId: model.route.panelId ?? '', base: props.adminBase, open: drawer.open, id: sidebarId, label: props.labels.navigation, railStorageKey: props.railStorageKey, railDefaultCollapsed: props.railDefaultCollapsed ?? false, children: [props.slots.sidebarHeader, _jsx(Sidebar.Nav, { groups: nav, soonLabel: props.labels.soon, collapsibleGroups: props.collapsibleGroups ?? [] }), _jsxs(Sidebar.Footer, { children: [_jsx(Sidebar.RailToggle, { expandLabel: props.labels.expandSidebar, collapseLabel: props.labels.collapseSidebar }), props.slots.sidebarFooter?.(slotArgs)] })] }), drawer.open && _jsx("button", { type: "button", className: "sidebar-backdrop", "aria-label": props.labels.closeNavigation, onClick: () => drawer.setOpen(false) }), _jsxs("div", { className: "admin-main-col", children: [_jsxs("div", { className: "admin-topbar", children: [_jsx("button", { type: "button", className: "admin-topbar-toggle", "aria-expanded": drawer.open, "aria-controls": sidebarId, "aria-label": drawer.open ? props.labels.closeNavigation : props.labels.openNavigation, onClick: () => drawer.setOpen((open) => !open), children: _jsx("svg", { viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { d: "M2.5 5h13M2.5 9h13M2.5 13h13" }) }) }), _jsx("span", { className: "admin-topbar-title", children: props.title }), props.slots.topbar] }), _jsx("main", { ref: props.mainRef, id: mainId, className: "admin-content", tabIndex: -1, "data-agent-page": model.agentPageId ?? undefined, children: model.panel?.render(slotArgs) })] }), assistant && _jsx("aside", { className: "admin-assistant-pane", "aria-label": assistant.label, hidden: !assistant.open, children: assistant.content }), props.slots.overlays?.(slotArgs)] }));
61
+ return (_jsxs("div", { className: ['admin-layout', props.className].filter(Boolean).join(' '), children: [_jsx("a", { href: `#${mainId}`, className: "skip-link", children: props.labels.skipToContent }), _jsx(ShellSidebar, { props: props, view: view, open: drawer.open, id: sidebarId }), drawer.open && _jsx("button", { type: "button", className: "sidebar-backdrop", "aria-label": props.labels.closeNavigation, onClick: () => drawer.setOpen(false) }), _jsxs("div", { className: "admin-main-col", children: [_jsxs("div", { className: "admin-topbar", children: [_jsx("button", { type: "button", className: "admin-topbar-toggle", "aria-expanded": drawer.open, "aria-controls": sidebarId, "aria-label": drawer.open ? props.labels.closeNavigation : props.labels.openNavigation, onClick: () => drawer.setOpen((open) => !open), children: _jsx("svg", { viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { d: "M2.5 5h13M2.5 9h13M2.5 13h13" }) }) }), _jsx("span", { className: "admin-topbar-title", children: props.title }), props.slots.topbar] }), _jsx("main", { ref: props.mainRef, id: mainId, className: "admin-content", tabIndex: -1, "data-agent-page": model.agentPageId ?? undefined, children: model.panel?.render(slotArgs) })] }), assistant && _jsx("aside", { className: "admin-assistant-pane", "aria-label": assistant.label, hidden: !assistant.open, children: assistant.content }), props.slots.overlays?.(slotArgs)] }));
45
62
  }
46
63
  //# sourceMappingURL=AdminShell.js.map
@@ -4,7 +4,10 @@ import type { AdminShellPanel, AdminShellSession } from './types.js';
4
4
  export interface AdminShellModel {
5
5
  readonly panels: readonly AdminShellPanel[];
6
6
  readonly panel: AdminShellPanel | undefined;
7
- readonly route: AdminRoute;
7
+ /** Always names a panel: an unmatched or root path resolves to the host's default panel. */
8
+ readonly route: AdminRoute & {
9
+ readonly panelId: string;
10
+ };
8
11
  readonly nav: readonly AdminNavGroup[];
9
12
  readonly agentPageId: string | null;
10
13
  }
@@ -12,12 +12,13 @@ export function resolveAdminShellModel(args, options = {}) {
12
12
  permissions: args.session.effectivePermissions ?? [],
13
13
  });
14
14
  const matched = matchRoute({ routePath: args.routePath, panels });
15
- const rawPath = args.routePath.split('?')[0] ?? '';
15
+ // `split` with a string separator always returns at least one element, so index 0 exists.
16
+ const rawPath = args.routePath.split('?')[0];
16
17
  const isRoot = rawPath.split('/').filter(Boolean).length === 0;
17
18
  // The existing matcher maps root to dashboard. The shell's default is host-owned.
18
19
  const route = isRoot || matched.panelId === null
19
20
  ? { panelId: args.defaultPanelId, view: null, params: {}, query: matched.query }
20
- : matched;
21
+ : { ...matched, panelId: matched.panelId };
21
22
  return {
22
23
  panels,
23
24
  panel: panels.find((panel) => panel.id === route.panelId),
@@ -1,4 +1,24 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ /** Run one port operation and map its outcome, including adapter failures, to the next gate state. */
3
+ async function settleOperation({ operation, port, context }) {
4
+ try {
5
+ if (operation === 'logout') {
6
+ await port.logout(context);
7
+ return { status: 'anonymous' };
8
+ }
9
+ const session = await port.read(context);
10
+ return session === null
11
+ ? { status: 'anonymous' }
12
+ : { status: 'authenticated', session };
13
+ }
14
+ catch (error) {
15
+ // Local sign-out must complete even when the server cannot revoke its cookie. A read
16
+ // retry here would quietly restore that still-valid session instead of showing login.
17
+ return operation === 'logout'
18
+ ? { status: 'anonymous' }
19
+ : { status: 'error', error };
20
+ }
21
+ }
2
22
  /**
3
23
  * Own session reads and invalidation without any host API imports.
4
24
  * @param args Stable context and session port. Changing either starts a new session check.
@@ -22,33 +42,12 @@ export function useAdminShellSession(args) {
22
42
  const request = ++generation.current;
23
43
  const isCurrent = () => active.current === owner && generation.current === request;
24
44
  setOwned({ context, port, state: { status: 'checking' } });
25
- try {
26
- if (operation === 'logout') {
27
- await port.logout(context);
28
- if (isCurrent())
29
- setOwned({ context, port, state: { status: 'anonymous' } });
30
- return;
31
- }
32
- const session = await port.read(context);
33
- if (!isCurrent())
34
- return;
35
- const state = session === null
36
- ? { status: 'anonymous' }
37
- : { status: 'authenticated', session };
45
+ // settleOperation never rejects, so the logout latch below always clears.
46
+ const state = await settleOperation({ operation, port, context });
47
+ if (isCurrent())
38
48
  setOwned({ context, port, state });
39
- }
40
- catch (error) {
41
- // Local sign-out must complete even when the server cannot revoke its cookie. A read
42
- // retry here would quietly restore that still-valid session instead of showing login.
43
- if (isCurrent())
44
- setOwned({ context, port, state: operation === 'logout'
45
- ? { status: 'anonymous' }
46
- : { status: 'error', error } });
47
- }
48
- finally {
49
- if (operation === 'logout' && loggingOut.current === owner)
50
- loggingOut.current = null;
51
- }
49
+ if (operation === 'logout' && loggingOut.current === owner)
50
+ loggingOut.current = null;
52
51
  }, [context, port]);
53
52
  const refresh = useCallback(() => run('read'), [run]);
54
53
  const logout = useCallback(() => run('logout'), [run]);
@@ -6,6 +6,8 @@ import { AdminApiError } from '../../core/transport/errors.js';
6
6
  export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { validate, onImport } = {}) {
7
7
  const rows = new Map(redirects.map(row => [row.id, Object.freeze({ ...row })]));
8
8
  const stats = new Map(Object.entries(hits).map(([id, value]) => [id, Object.freeze({ ...value })]));
9
+ // Ids removed to the Trash: hidden from the list, still readable by id, restored by `status: 'active'`.
10
+ const trashed = new Set();
9
11
  let nextId = 1;
10
12
  function get(id) {
11
13
  const row = rows.get(id);
@@ -32,7 +34,8 @@ export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { valida
32
34
  }
33
35
  return {
34
36
  async listRedirects(_required, filter = {}) {
35
- return Object.freeze([...rows.values()].filter(row => (filter.status === undefined || row.status === filter.status) &&
37
+ return Object.freeze([...rows.values()].filter(row => !trashed.has(row.id) &&
38
+ (filter.status === undefined || row.status === filter.status) &&
36
39
  (filter.source === undefined || row.source === filter.source) &&
37
40
  (filter.matchType === undefined || row.matchType === filter.matchType)));
38
41
  },
@@ -47,14 +50,18 @@ export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { valida
47
50
  throw new AdminApiError({ message: 'regex match type is not supported', status: 400 }, { code: 'REDIRECT_VALIDATION_ERROR' });
48
51
  const row = Object.freeze({ ...next, version: before.version + 1 });
49
52
  rows.set(id, row);
53
+ if (patch.status === 'active')
54
+ trashed.delete(id);
50
55
  return row;
51
56
  },
52
57
  async tombstoneRedirect({ id }) {
53
58
  const row = get(id);
54
- if (row.status === 'disabled')
59
+ // Keyed on the Trash, not on `status`: a rule already toggled off still moves to the Trash.
60
+ if (trashed.has(id))
55
61
  return row;
56
62
  const disabled = Object.freeze({ ...row, status: 'disabled', version: row.version + 1 });
57
63
  rows.set(id, disabled);
64
+ trashed.add(id);
58
65
  return disabled;
59
66
  },
60
67
  async getRedirectHitStats({ id }) {
@@ -1,17 +1,14 @@
1
1
  import { AdminApiError } from '../../core/transport/errors.js';
2
- /** Destructive framework-free checklist: use an isolated backend with no production data.
3
- * Validates core soft-delete, snapshots, lazy-read data and per-item partial import contracts.
4
- * Reference regex rejection probes can be disabled for a host implementing the port's open vocabulary.
5
- * @example await runRedirectsApiConformance({ api: createMemoryRedirectsApi({}, {}) }, {});
6
- */
7
- export async function runRedirectsApiConformance({ api }, { referenceRegexRejection = true } = {}) {
8
- const checks = [];
9
- function assert(ok, name) {
2
+ /** The id every not-found probe asks for; no conforming backend may hold it. */
3
+ const MISSING = { id: 'missing-conformance-rule' };
4
+ function createChecklist(_required = {}, _optional = {}) {
5
+ const passed = [];
6
+ function check({ ok, name }) {
10
7
  if (!ok)
11
8
  throw new AdminApiError({ message: `Redirects API conformance: ${name}`, status: 0 }, { code: 'CONFORMANCE_FAILED' });
12
- checks.push(name);
9
+ passed.push(name);
13
10
  }
14
- async function rejects(run, name) {
11
+ async function rejects({ run, name }) {
15
12
  let rejected = false;
16
13
  try {
17
14
  await run();
@@ -19,48 +16,81 @@ export async function runRedirectsApiConformance({ api }, { referenceRegexReject
19
16
  catch {
20
17
  rejected = true;
21
18
  }
22
- assert(rejected, name);
19
+ check({ ok: rejected, name });
23
20
  }
24
- const input = { matchType: 'exact', fromPattern: '/redirects-conformance-a', toTarget: '/redirects-conformance-b', statusCode: 301 };
21
+ return { passed, check, rejects };
22
+ }
23
+ async function checkCreateAndRead({ api, list, input }, _optional = {}) {
25
24
  const created = await api.createRedirect(input, { override: true, priority: 7 });
26
- assert(created.status === 'active' && created.override && created.priority === 7, 'create preserves fields and options');
25
+ list.check({ ok: created.status === 'active' && created.override && created.priority === 7, name: 'create preserves fields and options' });
27
26
  const before = await api.listRedirects({});
28
- assert(before.some(row => row.id === created.id), 'list contains the created rule');
29
- assert((await api.getRedirect({ id: created.id })).id === created.id, 'get reads by id');
27
+ list.check({ ok: before.some(row => row.id === created.id), name: 'list contains the created rule' });
28
+ list.check({ ok: (await api.getRedirect({ id: created.id })).id === created.id, name: 'get reads by id' });
30
29
  const hits = await api.getRedirectHitStats({ id: created.id });
31
- assert(hits.redirectId === created.id && hits.hitCount === 0 && hits.lastHitAt === null, 'new rule has zero hits');
30
+ list.check({ ok: hits.redirectId === created.id && hits.hitCount === 0 && hits.lastHitAt === null, name: 'new rule has zero hits' });
31
+ return { created, before };
32
+ }
33
+ async function checkUpdate({ api, list, created, before }, _optional = {}) {
32
34
  const updated = await api.updateRedirect({ id: created.id }, { status: 'disabled' });
33
- assert(updated.status === 'disabled' && updated.toTarget === created.toTarget, 'partial update preserves other fields');
34
- assert(created.status === 'active' && before.find(row => row.id === created.id)?.status === 'active', 'prior snapshots stay unchanged');
35
- assert((await api.listRedirects({}, { status: 'disabled' })).every(row => row.status === 'disabled'), 'status filter');
35
+ list.check({ ok: updated.status === 'disabled' && updated.toTarget === created.toTarget, name: 'partial update preserves other fields' });
36
+ list.check({ ok: created.status === 'active' && before.find(row => row.id === created.id)?.status === 'active', name: 'prior snapshots stay unchanged' });
37
+ list.check({ ok: (await api.listRedirects({}, { status: 'disabled' })).every(row => row.status === 'disabled'), name: 'status filter' });
36
38
  await api.updateRedirect({ id: created.id }, { status: 'active' });
37
- const disabled = await api.tombstoneRedirect({ id: created.id });
38
- assert(disabled.status === 'disabled', 'tombstone returns the disabled rule');
39
- assert((await api.listRedirects({})).some(row => row.id === created.id), 'soft deletion retains the rule');
40
- assert((await api.getRedirect({ id: created.id })).status === 'disabled', 'tombstoned rule remains readable');
41
- await rejects(() => api.getRedirect({ id: 'missing-conformance-rule' }), 'missing rule rejects');
42
- await rejects(() => api.getRedirectHitStats({ id: 'missing-conformance-rule' }), 'missing hits differs from zero hits');
43
- await rejects(() => api.updateRedirect({ id: 'missing-conformance-rule' }, { status: 'disabled' }), 'missing update rejects');
44
- await rejects(() => api.tombstoneRedirect({ id: 'missing-conformance-rule' }), 'missing tombstone rejects');
45
- if (referenceRegexRejection) {
46
- await rejects(() => api.createRedirect({ ...input, fromPattern: '/regex-conformance', matchType: 'regex' }), 'reference regex create is loudly rejected');
47
- await rejects(() => api.updateRedirect({ id: created.id }, { matchType: 'regex' }), 'reference regex update is loudly rejected');
48
- }
39
+ }
40
+ async function checkSoftDelete({ api, list, id }, _optional = {}) {
41
+ const disabled = await api.tombstoneRedirect({ id });
42
+ list.check({ ok: disabled.status === 'disabled', name: 'tombstone returns the disabled rule' });
43
+ // Removal moves the rule to the Trash: it leaves the list and is shown only there (Bug B).
44
+ list.check({ ok: !(await api.listRedirects({})).some(row => row.id === id), name: 'removed rule leaves the list' });
45
+ list.check({ ok: (await api.getRedirect({ id })).status === 'disabled', name: 'tombstoned rule remains readable' });
46
+ // A pure `status: 'active'` update is the restore; remove the rule again so the run leaves it in the Trash.
47
+ await api.updateRedirect({ id }, { status: 'active' });
48
+ list.check({ ok: (await api.listRedirects({})).some(row => row.id === id), name: 'restore returns the rule to the list' });
49
+ await api.tombstoneRedirect({ id });
50
+ }
51
+ async function checkMissingRule({ api, list }, _optional = {}) {
52
+ await list.rejects({ run: () => api.getRedirect(MISSING), name: 'missing rule rejects' });
53
+ await list.rejects({ run: () => api.getRedirectHitStats(MISSING), name: 'missing hits differs from zero hits' });
54
+ await list.rejects({ run: () => api.updateRedirect(MISSING, { status: 'disabled' }), name: 'missing update rejects' });
55
+ await list.rejects({ run: () => api.tombstoneRedirect(MISSING), name: 'missing tombstone rejects' });
56
+ }
57
+ async function checkReferenceRegexRejection({ api, list, input, id }, _optional = {}) {
58
+ await list.rejects({ run: () => api.createRedirect({ ...input, fromPattern: '/regex-conformance', matchType: 'regex' }), name: 'reference regex create is loudly rejected' });
59
+ await list.rejects({ run: () => api.updateRedirect({ id }, { matchType: 'regex' }), name: 'reference regex update is loudly rejected' });
60
+ }
61
+ /** With reference rejection, a regex item sits between two valid ones to prove per-item partial success. */
62
+ async function checkImport({ api, list, input }, { referenceRegexRejection }) {
49
63
  const batch = await api.importRedirects({ rules: [
50
64
  { ...input, fromPattern: '/import-conformance-a' },
51
65
  ...(referenceRegexRejection ? [{ ...input, fromPattern: '/import-conformance-regex', matchType: 'regex' }] : []),
52
66
  { ...input, fromPattern: '/import-conformance-c' },
53
67
  ] });
54
68
  if (referenceRegexRejection) {
55
- assert(batch.created.length === 2 && batch.failed.length === 1 && batch.failed[0]?.index === 1, 'middle failure preserves both successful siblings');
56
- assert(batch.created.every(row => row.fromPattern !== '/import-conformance-regex'), 'invalid item is never reported created');
69
+ list.check({ ok: batch.created.length === 2 && batch.failed.length === 1 && batch.failed[0]?.index === 1, name: 'middle failure preserves both successful siblings' });
70
+ list.check({ ok: batch.created.every(row => row.fromPattern !== '/import-conformance-regex'), name: 'invalid item is never reported created' });
57
71
  }
58
72
  else {
59
- assert(batch.created.length === 2 && batch.failed.length === 0, 'valid import batch succeeds');
73
+ list.check({ ok: batch.created.length === 2 && batch.failed.length === 0, name: 'valid import batch succeeds' });
60
74
  }
61
- assert(batch.created.every(row => row.source === 'import'), 'import provenance is stamped by the backend');
75
+ list.check({ ok: batch.created.every(row => row.source === 'import'), name: 'import provenance is stamped by the backend' });
62
76
  for (const row of batch.created)
63
77
  await api.tombstoneRedirect({ id: row.id });
64
- return Object.freeze(checks);
78
+ }
79
+ /** Destructive framework-free checklist: use an isolated backend with no production data.
80
+ * Validates core soft-delete, snapshots, lazy-read data and per-item partial import contracts.
81
+ * Reference regex rejection probes can be disabled for a host implementing the port's open vocabulary.
82
+ * @example await runRedirectsApiConformance({ api: createMemoryRedirectsApi({}, {}) }, {});
83
+ */
84
+ export async function runRedirectsApiConformance({ api }, { referenceRegexRejection = true } = {}) {
85
+ const list = createChecklist();
86
+ const input = { matchType: 'exact', fromPattern: '/redirects-conformance-a', toTarget: '/redirects-conformance-b', statusCode: 301 };
87
+ const { created, before } = await checkCreateAndRead({ api, list, input });
88
+ await checkUpdate({ api, list, created, before });
89
+ await checkSoftDelete({ api, list, id: created.id });
90
+ await checkMissingRule({ api, list });
91
+ if (referenceRegexRejection)
92
+ await checkReferenceRegexRejection({ api, list, input, id: created.id });
93
+ await checkImport({ api, list, input }, { referenceRegexRejection });
94
+ return Object.freeze(list.passed);
65
95
  }
66
96
  //# sourceMappingURL=redirects-api.conformance.js.map
@@ -9,7 +9,7 @@ export declare function useImportRedirectsForm({ api }: {
9
9
  locale?: string;
10
10
  }): {
11
11
  raw: string;
12
- setRaw: (raw: string) => void | undefined;
12
+ setRaw: (next: string) => void | undefined;
13
13
  error: string | null;
14
14
  result: import("../../index.js").AdminRedirectImportResult | null;
15
15
  importing: boolean;
@@ -5,6 +5,13 @@ import { useController } from '../../../core/react/use-controller.js';
5
5
  import { createImportRedirectsController } from '../../controllers/import-redirects.controller.js';
6
6
  import { KEYS } from '../../rules.js';
7
7
  import { translateRedirectsEn } from '../../messages.en.js';
8
+ /** Before the controller mounts there is no snapshot: an empty form. A parse error wins over a
9
+ * request error, so a later parse failure replaces an earlier request failure instead of stacking. */
10
+ function deriveFormState({ snapshot, requestError }, _optional = {}) {
11
+ return { raw: snapshot?.raw ?? '',
12
+ error: snapshot?.parseError ?? (requestError ? describeApiError({ e: requestError, fallback: 'Import failed' }) : null),
13
+ result: snapshot?.result ?? null };
14
+ }
8
15
  /** A resolved 207 is a result, including failed items; only transport failures enter error state. */
9
16
  export function useImportRedirectsForm({ api }, { t = translateRedirectsEn, locale = 'en' } = {}) {
10
17
  const mutation = useFetchMutation({ run: ({ input: rules }) => api.importRedirects({ rules }) }, { invalidates: [KEYS.list] });
@@ -18,8 +25,8 @@ export function useImportRedirectsForm({ api }, { t = translateRedirectsEn, loca
18
25
  e.preventDefault();
19
26
  return controller?.submit() ?? Promise.resolve();
20
27
  }
21
- return { raw: snapshot?.raw ?? '', setRaw: (raw) => controller?.setRaw({ raw }),
22
- error: snapshot?.parseError ?? (mutation.error ? describeApiError({ e: mutation.error, fallback: 'Import failed' }) : null),
23
- result: snapshot?.result ?? null, importing: mutation.status === 'pending', submit, t, locale };
28
+ const { raw, error, result } = deriveFormState({ snapshot, requestError: mutation.error });
29
+ return { raw, setRaw: (next) => controller?.setRaw({ raw: next }),
30
+ error, result, importing: mutation.status === 'pending', submit, t, locale };
24
31
  }
25
32
  //# sourceMappingURL=use-import-redirects-form.hooks.js.map
@@ -44,7 +44,7 @@ export declare function useWiredImportRedirectsForm({ t, locale }: {
44
44
  createdText: string;
45
45
  failedLabel: (failure: AdminRedirectImportFailure) => string;
46
46
  raw: string;
47
- setRaw: (raw: string) => void | undefined;
47
+ setRaw: (next: string) => void | undefined;
48
48
  error: string | null;
49
49
  result: import("../../index.js").AdminRedirectImportResult | null;
50
50
  importing: boolean;
@@ -3,8 +3,17 @@ function summary(row, kind) {
3
3
  return Object.freeze({ kind, id: row.id, providerId: row.providerId ?? '', label: row.label, configured: row.configured, isDefault: row.isDefault ?? false, createdAt: row.createdAt, updatedAt: row.updatedAt,
4
4
  ...(row.accountLabel !== undefined ? { accountLabel: row.accountLabel } : {}), ...(kind === 'custom' ? { category: row.category ?? 'general', baseUrl: row.baseUrl ?? '', additionalHosts: Object.freeze([...(row.additionalHosts ?? [])]), ...(row.username ? { username: row.username } : {}) } : {}) });
5
5
  }
6
+ function trimBase({ workspacePath }, _optional = {}) { return workspacePath.replace(/\/$/, ''); }
7
+ function descriptorExtras({ credential }, _optional = {}) {
8
+ const help = credential?.userHelp ?? credential?.help;
9
+ return { ...(credential?.tokenPageUrl ? { tokenPageUrl: credential.tokenPageUrl } : {}), ...(help ? { help } : {}) };
10
+ }
11
+ function providerFromDescriptor({ kind, descriptor: d }, _optional = {}) {
12
+ const c = d.credential;
13
+ return { kind, id: d.id, label: d.label, vendorLabel: c?.vendorLabel ?? d.label, category: kind === 'publish' ? 'hosting' : 'source-control', tokenField: c?.tokenField ?? 'token', fields: c?.fields ?? [], ...descriptorExtras({ credential: c }) };
14
+ }
6
15
  export function createHttpSecurityApi({ transport, workspacePath }, _optional = {}) {
7
- const base = workspacePath.replace(/\/$/, '');
16
+ const base = trimBase({ workspacePath });
8
17
  function store(kind, suffix) {
9
18
  const path = `${base}/system/${suffix}/credentials`;
10
19
  async function mutate(method, target, body, options) { options.signal?.throwIfAborted(); const r = await transport.request({ method, path: target, body }, options); return summary(r.credential, kind); }
@@ -22,24 +31,28 @@ export function createHttpSecurityApi({ transport, workspacePath }, _optional =
22
31
  transport.request({ method: 'GET', path: `${base}/system/publish-targets` }, options),
23
32
  transport.request({ method: 'GET', path: `${base}/system/source-control/providers` }, options),
24
33
  ]);
25
- const map = (kind, d) => ({ kind, id: d.id, label: d.label, vendorLabel: d.credential?.vendorLabel ?? d.label, category: kind === 'publish' ? 'hosting' : 'source-control', tokenField: d.credential?.tokenField ?? 'token', fields: d.credential?.fields ?? [], ...(d.credential?.tokenPageUrl ? { tokenPageUrl: d.credential.tokenPageUrl } : {}), ...((d.credential?.userHelp ?? d.credential?.help) ? { help: d.credential.userHelp ?? d.credential.help } : {}) });
26
- return [...publish.targets.filter(d => d.credential).map(d => map('publish', d)), ...source.providers.map(d => map('source-control', d))];
34
+ return [...publish.targets.filter(d => d.credential).map(descriptor => providerFromDescriptor({ kind: 'publish', descriptor })), ...source.providers.map(descriptor => providerFromDescriptor({ kind: 'source-control', descriptor }))];
27
35
  },
28
36
  publish: store('publish', 'publish'), sourceControl: store('source-control', 'source-control'), custom: store('custom', 'custom'),
29
37
  };
30
38
  }
39
+ /** Only non-secret settings are echoed back; apiKey is sent solely for the entry being replaced. */
40
+ function mediaEntry({ entry }, { apiKey } = {}) {
41
+ return { ...(entry.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}), ...(entry.model !== undefined ? { model: entry.model } : {}), ...(apiKey !== undefined ? { apiKey } : {}) };
42
+ }
31
43
  /** Replacing the ENTIRE map requires sibling settings unchanged. Blank apiKey preserves their stored keys. */
32
44
  function rebuildMedia(current, id, token) {
33
45
  const next = {};
34
46
  for (const [key, entry] of Object.entries(current)) {
35
- if (key === id && token === undefined)
36
- continue;
37
- next[key] = { ...(entry.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}), ...(entry.model !== undefined ? { model: entry.model } : {}), ...(key === id && token !== undefined ? { apiKey: token } : {}) };
47
+ if (key !== id)
48
+ next[key] = mediaEntry({ entry });
49
+ else if (token !== undefined)
50
+ next[key] = mediaEntry({ entry }, { apiKey: token });
38
51
  }
39
52
  return next;
40
53
  }
41
54
  export function createHttpOtherCredentials({ transport, workspacePath }, { mediaLabels = {} } = {}) {
42
- const base = workspacePath.replace(/\/$/, '');
55
+ const base = trimBase({ workspacePath });
43
56
  const singles = { 'site-assistant': { path: '/assistant/site-credential', label: 'Assistant model key' }, 'admin-byok': { path: '/assistant/execution-credential', label: 'Admin model key' } };
44
57
  async function request(path, method, options, body) { options.signal?.throwIfAborted(); return transport.request({ path: `${base}${path}`, method, ...(body !== undefined ? { body } : {}) }, options); }
45
58
  return {
@@ -79,15 +92,17 @@ export function createHttpOtherCredentials({ transport, workspacePath }, { media
79
92
  },
80
93
  };
81
94
  }
95
+ /** Explicit projection: receivers carry signing secrets the UI must never hold. */
96
+ function webhookTargets({ webhooks }, _optional = {}) { return webhooks.map(w => ({ label: w.label, targetUrl: w.targetUrl })); }
82
97
  export function createHttpRootKey({ transport, workspacePath }, _optional = {}) {
83
- const path = `${workspacePath.replace(/\/$/, '')}/system/site-token`;
98
+ const path = `${trimBase({ workspacePath })}/system/site-token`;
84
99
  async function request(suffix, method, options, body) { options.signal?.throwIfAborted(); return transport.request({ path: `${path}${suffix}`, method, ...(body !== undefined ? { body } : {}) }, options); }
85
- function result(r) { return { outcome: r.outcome, fingerprint: r.fingerprint, keyFilePath: r.keyFilePath, runtimeMode: r.runtimeMode, ...(r.resealed !== undefined ? { resealed: r.resealed } : {}), ...(r.discarded !== undefined ? { discarded: r.discarded } : {}), ...(r.kept !== undefined ? { kept: r.kept } : {}), ...(r.restorePointId ? { restorePointId: r.restorePointId } : {}), ...(r.affectedWebhooks ? { affectedWebhooks: r.affectedWebhooks.map(w => ({ label: w.label, targetUrl: w.targetUrl })) } : {}) }; }
100
+ function result(r) { return { outcome: r.outcome, fingerprint: r.fingerprint, keyFilePath: r.keyFilePath, runtimeMode: r.runtimeMode, ...(r.resealed !== undefined ? { resealed: r.resealed } : {}), ...(r.discarded !== undefined ? { discarded: r.discarded } : {}), ...(r.kept !== undefined ? { kept: r.kept } : {}), ...(r.restorePointId ? { restorePointId: r.restorePointId } : {}), ...(r.affectedWebhooks ? { affectedWebhooks: webhookTargets({ webhooks: r.affectedWebhooks }) } : {}) }; }
86
101
  return {
87
102
  async status(_r, o = {}) { const r = await request('', 'GET', o); return { active: r.active, source: r.source, state: r.state, keyFilePath: r.keyFilePath, runtimeMode: r.runtimeMode, ...(r.fingerprint ? { fingerprint: r.fingerprint } : {}), ...(r.invalid !== undefined ? { invalid: r.invalid } : {}) }; },
88
103
  async generate(_r, o = {}) { return result(await request('/generate', 'POST', o)); },
89
104
  async importToken({ token }, o = {}) { return result(await request('/import', 'POST', o, { token })); },
90
- async previewStartFresh(_r, o = {}) { const r = await request('/start-fresh', 'GET', o); return { removes: r.removes, detail: r.detail, runtimeMode: r.runtimeMode, affectedWebhooks: r.affectedWebhooks.map(w => ({ label: w.label, targetUrl: w.targetUrl })) }; },
105
+ async previewStartFresh(_r, o = {}) { const r = await request('/start-fresh', 'GET', o); return { removes: r.removes, detail: r.detail, runtimeMode: r.runtimeMode, affectedWebhooks: webhookTargets({ webhooks: r.affectedWebhooks }) }; },
91
106
  async startFresh({ confirm }, o = {}) { return result(await request('/start-fresh', 'POST', o, { confirm })); },
92
107
  };
93
108
  }