@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
@@ -6,79 +6,116 @@ export class AdminConfigError extends Error {
6
6
  this.issues = Object.freeze([...issues]);
7
7
  }
8
8
  }
9
- export function createAdmin({ modules, ports, }, options = {}) {
10
- const issues = [];
11
- const tokens = new Map();
12
- const identities = new Map();
13
- const providers = new Map();
14
- const ids = new Set(), paths = new Set(), tabIds = new Set();
15
- const requiredNames = new Set();
16
- const host = ports;
17
- const values = { ...host };
18
- const omitted = new Set(options.omit ?? []);
19
- const grants = new Set(options.permissions ?? []);
20
- const allowed = (permissions = []) => permissions.every((p) => grants.has(p));
21
- function collect(map = {}, required = false) {
22
- for (const [name, tok] of Object.entries(map)) {
23
- const old = tokens.get(name);
24
- if (!tok.id || tok.cardinality !== 'one' || !Number.isInteger(tok.version) || tok.version < 1)
25
- issues.push(`Invalid token: ${name}`);
26
- if (old && (old.id !== tok.id || old.version !== tok.version))
27
- issues.push(`Incompatible token: ${name}`);
28
- const alias = identities.get(tok.id);
29
- if (alias && alias !== name)
30
- issues.push(`Token ${tok.id} has conflicting names: ${alias}, ${name}`);
31
- identities.set(tok.id, name);
32
- tokens.set(name, tok);
33
- if (required)
34
- requiredNames.add(name);
9
+ function isValidToken(tok) {
10
+ return (Boolean(tok.id) &&
11
+ tok.cardinality === 'one' &&
12
+ Number.isInteger(tok.version) &&
13
+ tok.version >= 1);
14
+ }
15
+ function checkToken({ registry, name, tok }, _optional = {}) {
16
+ const { issues } = registry;
17
+ if (!isValidToken(tok))
18
+ issues.push(`Invalid token: ${name}`);
19
+ const old = registry.tokens.get(name);
20
+ if (old && (old.id !== tok.id || old.version !== tok.version))
21
+ issues.push(`Incompatible token: ${name}`);
22
+ const alias = registry.identities.get(tok.id);
23
+ if (alias && alias !== name)
24
+ issues.push(`Token ${tok.id} has conflicting names: ${alias}, ${name}`);
25
+ }
26
+ function collectTokens({ registry, map }, { required = false } = {}) {
27
+ for (const [name, tok] of Object.entries(map ?? {})) {
28
+ checkToken({ registry, name, tok });
29
+ registry.identities.set(tok.id, name);
30
+ registry.tokens.set(name, tok);
31
+ if (required)
32
+ registry.requiredNames.add(name);
33
+ }
34
+ }
35
+ function checkProvider({ registry, module, name, overrides, }, _optional = {}) {
36
+ const { issues, providers } = registry;
37
+ if (providers.has(name))
38
+ issues.push(`Duplicate provider: ${name}`);
39
+ providers.set(name, module);
40
+ const hosted = registry.host[name] != null;
41
+ if (hosted && !overrides.includes(name))
42
+ issues.push(`Provider collision: ${name}; declare an override`);
43
+ if (!module.factories?.[name] && !hosted)
44
+ issues.push(`Missing provider factory: ${name}`);
45
+ }
46
+ function registerProviders({ registry, module, overrides, }, _optional = {}) {
47
+ const provides = module.provides ?? {};
48
+ for (const name of Object.keys(provides))
49
+ checkProvider({ registry, module, name, overrides });
50
+ for (const name of Object.keys(module.factories ?? {}))
51
+ if (!(name in provides))
52
+ registry.issues.push(`Undeclared provider factory: ${name}`);
53
+ }
54
+ function registerPages({ registry, module, omitted, }, _optional = {}) {
55
+ const { issues, paths, tabIds } = registry;
56
+ for (const [pageId, page] of Object.entries(module.pages)) {
57
+ if (paths.has(page.path))
58
+ issues.push(`Duplicate route: ${page.path}`);
59
+ paths.add(page.path);
60
+ for (const [tabId, tab] of Object.entries(page.tabs)) {
61
+ const full = `${module.id}.${pageId}.${tabId}`;
62
+ tabIds.add(full);
63
+ collectTokens({ registry, map: tab.requires }, { required: !omitted.has(full) });
64
+ collectTokens({ registry, map: tab.optional });
35
65
  }
36
66
  }
67
+ }
68
+ function registerModules({ modules, host, omitted, overrides, }, _optional = {}) {
69
+ const registry = {
70
+ host,
71
+ issues: [],
72
+ tokens: new Map(),
73
+ identities: new Map(),
74
+ providers: new Map(),
75
+ requiredNames: new Set(),
76
+ ids: new Set(),
77
+ paths: new Set(),
78
+ tabIds: new Set(),
79
+ };
37
80
  for (const module of modules) {
38
- if (ids.has(module.id))
39
- issues.push(`Duplicate module: ${module.id}`);
40
- ids.add(module.id);
41
- collect(module.requires, true);
42
- collect(module.optional);
43
- collect(module.provides);
44
- for (const name of Object.keys(module.provides ?? {})) {
45
- if (providers.has(name))
46
- issues.push(`Duplicate provider: ${name}`);
47
- providers.set(name, module);
48
- if (host[name] != null &&
49
- !options.overrides?.includes(name))
50
- issues.push(`Provider collision: ${name}; declare an override`);
51
- if (!module.factories?.[name] && host[name] == null)
52
- issues.push(`Missing provider factory: ${name}`);
53
- }
54
- for (const name of Object.keys(module.factories ?? {}))
55
- if (!(name in (module.provides ?? {})))
56
- issues.push(`Undeclared provider factory: ${name}`);
57
- for (const [pageId, page] of Object.entries(module.pages)) {
58
- if (paths.has(page.path))
59
- issues.push(`Duplicate route: ${page.path}`);
60
- paths.add(page.path);
61
- for (const [tabId, tab] of Object.entries(page.tabs)) {
62
- const full = `${module.id}.${pageId}.${tabId}`;
63
- tabIds.add(full);
64
- collect(tab.requires, !omitted.has(full));
65
- collect(tab.optional);
66
- }
67
- }
81
+ if (registry.ids.has(module.id))
82
+ registry.issues.push(`Duplicate module: ${module.id}`);
83
+ registry.ids.add(module.id);
84
+ collectTokens({ registry, map: module.requires }, { required: true });
85
+ collectTokens({ registry, map: module.optional });
86
+ collectTokens({ registry, map: module.provides });
87
+ registerProviders({ registry, module, overrides });
88
+ registerPages({ registry, module, omitted });
68
89
  }
90
+ return registry;
91
+ }
92
+ function checkOptions({ registry, omitted, overrides, }, _optional = {}) {
93
+ const { issues } = registry;
69
94
  for (const id of omitted)
70
- if (!tabIds.has(id))
95
+ if (!registry.tabIds.has(id))
71
96
  issues.push(`Unknown tab: ${id}`);
72
- for (const name of options.overrides ?? [])
73
- if (!providers.has(String(name)))
74
- issues.push(`Unknown provider override: ${String(name)}`);
97
+ for (const name of overrides)
98
+ if (!registry.providers.has(name))
99
+ issues.push(`Unknown provider override: ${name}`);
100
+ }
101
+ function checkPorts({ registry }, _optional = {}) {
102
+ const { host, issues, providers } = registry;
75
103
  for (const name of Object.keys(host))
76
- if (!tokens.has(name))
104
+ if (!registry.tokens.has(name))
77
105
  issues.push(`Unknown port: ${name}`);
78
- for (const name of requiredNames)
106
+ for (const name of registry.requiredNames)
79
107
  if (host[name] == null && !providers.has(name))
80
108
  issues.push(`Missing port: ${name}`);
81
- // Validate the graph before executing factories, so a boot error has no partial side effects.
109
+ }
110
+ function dependencies(module) {
111
+ const tabPorts = Object.values(module.pages)
112
+ .flatMap((page) => Object.values(page.tabs))
113
+ .reduce((result, tab) => ({ ...result, ...tab.requires, ...tab.optional }), {});
114
+ return { ...module.requires, ...module.optional, ...tabPorts };
115
+ }
116
+ /** Depth-first topological order of the providers the host does not already supply. */
117
+ function orderProviders({ registry }, _optional = {}) {
118
+ const { host, issues, providers } = registry;
82
119
  const sorted = [], visiting = new Set(), visited = new Set();
83
120
  function visit(name, chain) {
84
121
  if (visited.has(name) || host[name] != null)
@@ -88,8 +125,7 @@ export function createAdmin({ modules, ports, }, options = {}) {
88
125
  return;
89
126
  }
90
127
  visiting.add(name);
91
- const module = providers.get(name);
92
- for (const dep of Object.keys(dependencies(module)))
128
+ for (const dep of Object.keys(dependencies(providers.get(name))))
93
129
  if (providers.has(dep))
94
130
  visit(dep, [...chain, name]);
95
131
  visiting.delete(name);
@@ -98,68 +134,84 @@ export function createAdmin({ modules, ports, }, options = {}) {
98
134
  }
99
135
  for (const name of providers.keys())
100
136
  visit(name, []);
101
- if (issues.length)
102
- throw new AdminConfigError({ issues: [...new Set(issues)] });
137
+ return sorted;
138
+ }
139
+ function narrow({ module, values }) {
140
+ return Object.freeze(Object.fromEntries(Object.keys(dependencies(module)).map((name) => [name, values[name]])));
141
+ }
142
+ function isDisposable(value) {
143
+ return (typeof value === 'object' &&
144
+ value !== null &&
145
+ 'dispose' in value &&
146
+ typeof value.dispose === 'function');
147
+ }
148
+ function disposeAll(disposables) {
149
+ for (const value of [...disposables].reverse())
150
+ value.dispose();
151
+ }
152
+ /** Runs factories in dependency order; a failure disposes what was built and reports one issue. */
153
+ function instantiate({ providers, sorted, values, grants, }, _optional = {}) {
103
154
  const disposables = [];
104
- function dependencies(module) {
105
- const tabPorts = Object.values(module.pages)
106
- .flatMap((page) => Object.values(page.tabs))
107
- .reduce((result, tab) => ({ ...result, ...tab.requires, ...tab.optional }), {});
108
- return { ...module.requires, ...module.optional, ...tabPorts };
109
- }
110
- function narrow(module) {
111
- return Object.freeze(Object.fromEntries(Object.keys(dependencies(module)).map((name) => [name, values[name]])));
112
- }
113
155
  try {
114
156
  for (const name of sorted) {
115
- const value = providers.get(name).factories[name]({ ports: narrow(providers.get(name)), permissions: [...grants] }, {});
157
+ const module = providers.get(name);
158
+ const value = module.factories[name]({ ports: narrow({ module, values }), permissions: [...grants] }, {});
116
159
  if (value == null)
117
160
  throw new Error(`Provider returned no value: ${name}`);
118
161
  values[name] = value;
119
- if (typeof value === 'object' && 'dispose' in value && typeof value.dispose === 'function')
162
+ if (isDisposable(value))
120
163
  disposables.push(value);
121
164
  }
122
165
  }
123
166
  catch (error) {
124
- for (const value of disposables.reverse())
125
- value.dispose();
167
+ disposeAll(disposables);
126
168
  throw new AdminConfigError({
127
169
  issues: [error instanceof Error ? error.message : 'Provider initialization failed'],
128
170
  });
129
171
  }
130
- const description = Object.freeze({
131
- modules: Object.freeze([...ids]),
132
- pages: Object.freeze(modules.flatMap((module) => Object.entries(module.pages).map(([id, page]) => ({
133
- id: `${module.id}.${id}`,
134
- path: page.path,
135
- label: page.label,
136
- visible: allowed(page.permissions),
137
- agentReachable: page.agentReachable === true && allowed(page.permissions),
138
- permissions: page.permissions ?? [],
139
- grants: Object.freeze([...grants]),
140
- ...(page.nav ? { nav: page.nav } : {}),
141
- tabs: Object.entries(page.tabs).map(([tabId, tab]) => {
142
- const full = `${module.id}.${id}.${tabId}`;
143
- const absent = Object.keys(tab.optional ?? {}).find((name) => values[name] == null);
144
- const reason = omitted.has(full)
145
- ? 'omitted'
146
- : !allowed(page.permissions) || !allowed(tab.permissions)
147
- ? 'permission denied'
148
- : absent
149
- ? `optional port unavailable: ${absent}`
150
- : null;
151
- return {
152
- id: full,
153
- label: tab.label,
154
- visible: reason === null,
155
- reason,
156
- params: tab.params ?? {},
157
- agentReachable: reason === null && page.agentReachable === true && tab.agentReachable === true,
158
- };
159
- }),
160
- })))),
161
- ports: Object.freeze([...tokens]
162
- .filter(([name]) => values[name] != null)
172
+ return disposables;
173
+ }
174
+ const allowed = ({ grants }, permissions = []) => permissions.every((p) => grants.has(p));
175
+ function tabReason({ context, full, page, tab, }, _optional = {}) {
176
+ if (context.omitted.has(full))
177
+ return 'omitted';
178
+ if (!allowed(context, page.permissions) || !allowed(context, tab.permissions))
179
+ return 'permission denied';
180
+ const absent = Object.keys(tab.optional ?? {}).find((name) => context.values[name] == null);
181
+ return absent ? `optional port unavailable: ${absent}` : null;
182
+ }
183
+ function describePage({ context, module, id, page, }, _optional = {}) {
184
+ const visible = allowed(context, page.permissions);
185
+ return {
186
+ id: `${module.id}.${id}`,
187
+ path: page.path,
188
+ label: page.label,
189
+ visible,
190
+ agentReachable: page.agentReachable === true && visible,
191
+ permissions: page.permissions ?? [],
192
+ grants: Object.freeze([...context.grants]),
193
+ ...(page.nav ? { nav: page.nav } : {}),
194
+ tabs: Object.entries(page.tabs).map(([tabId, tab]) => {
195
+ const full = `${module.id}.${id}.${tabId}`;
196
+ const reason = tabReason({ context, full, page, tab });
197
+ return {
198
+ id: full,
199
+ label: tab.label,
200
+ visible: reason === null,
201
+ reason,
202
+ params: tab.params ?? {},
203
+ agentReachable: reason === null && page.agentReachable === true && tab.agentReachable === true,
204
+ };
205
+ }),
206
+ };
207
+ }
208
+ function describeAdmin({ modules, registry, context, }, _optional = {}) {
209
+ const { host } = registry;
210
+ return Object.freeze({
211
+ modules: Object.freeze([...registry.ids]),
212
+ pages: Object.freeze(modules.flatMap((module) => Object.entries(module.pages).map(([id, page]) => describePage({ context, module, id, page })))),
213
+ ports: Object.freeze([...registry.tokens]
214
+ .filter(([name]) => context.values[name] != null)
163
215
  .map(([name, tok]) => ({
164
216
  name,
165
217
  token: tok.id,
@@ -167,19 +219,45 @@ export function createAdmin({ modules, ports, }, options = {}) {
167
219
  source: host[name] != null ? 'host' : 'module',
168
220
  }))),
169
221
  });
222
+ }
223
+ export function createAdmin({ modules, ports, }, options = {}) {
224
+ const host = ports;
225
+ const values = { ...host };
226
+ const omitted = new Set(options.omit ?? []);
227
+ const overrides = (options.overrides ?? []).map(String);
228
+ const grants = new Set(options.permissions ?? []);
229
+ const registry = registerModules({ modules, host, omitted, overrides });
230
+ checkOptions({ registry, omitted, overrides });
231
+ checkPorts({ registry });
232
+ // Validate the graph before executing factories, so a boot error has no partial side effects.
233
+ const sorted = orderProviders({ registry });
234
+ if (registry.issues.length)
235
+ throw new AdminConfigError({ issues: [...new Set(registry.issues)] });
236
+ const disposables = instantiate({
237
+ providers: registry.providers,
238
+ sorted,
239
+ values,
240
+ grants: [...grants],
241
+ });
242
+ const description = describeAdmin({
243
+ modules,
244
+ registry,
245
+ context: { values, omitted, grants },
246
+ });
170
247
  let disposed = false;
171
248
  return {
172
249
  describe: () => description,
173
250
  scope: ({ module }) => {
174
251
  if (disposed || !modules.includes(module))
175
- throw new AdminConfigError({ issues: [`Module scope unavailable: ${module.id}`] });
176
- return narrow(module);
252
+ throw new AdminConfigError({
253
+ issues: [`Module scope unavailable: ${module.id}`],
254
+ });
255
+ return narrow({ module, values });
177
256
  },
178
257
  dispose: () => {
179
258
  if (!disposed) {
180
259
  disposed = true;
181
- for (const value of disposables.reverse())
182
- value.dispose();
260
+ disposeAll(disposables);
183
261
  }
184
262
  },
185
263
  };
@@ -18,12 +18,14 @@
18
18
  * has no `slug` field at all) — kept here because the union is OPEN and a different host may
19
19
  * legitimately implement `regex` even though the reference implementation refuses it.
20
20
  *
21
- * ## `tombstoneRedirect` is a soft delete
21
+ * ## `tombstoneRedirect` moves the rule to the Trash
22
22
  *
23
- * Mirrors `AdminIntegrationsPort.deleteIntegrationSubscription`, not `AdminMediaPort.deleteMedia`:
24
- * the rule's `status` flips to `"disabled"` and the row is retained (audit trail, still readable
25
- * via `listRedirects`/`getRedirect`), never hard-purged. The method resolves to the updated record
26
- * (200-style), not `void` — a panel can render the new state without a re-fetch.
23
+ * The rule's `status` flips to `"disabled"` and the row is retained (audit trail, still readable
24
+ * via `getRedirect`), never hard-purged here — but it LEAVES `listRedirects` (Bug B, 2026-10-22):
25
+ * a removed rule is shown only in the host's Trash, unlike a rule merely toggled off, which stays
26
+ * listed as `"disabled"`. Removing a toggled-off rule moves it to the Trash too. A pure
27
+ * `updateRedirect({ status: "active" })` (or the host's Trash restore) brings it back to the list.
28
+ * The method resolves to the updated record (200-style), not `void`.
27
29
  *
28
30
  * ## `source: "auto_slug_change"` is read-only provenance
29
31
  *
@@ -131,7 +133,7 @@ export interface AdminRedirectsPort {
131
133
  updateRedirect(requiredArgs: {
132
134
  id: string;
133
135
  }, optionalArgs?: AdminRedirectUpdatePatch): Promise<AdminRedirect>;
134
- /** Soft delete — returns the tombstoned record, not `void`. See file header. */
136
+ /** Moves the rule to the Trash (off `listRedirects`) — returns the tombstoned record, not `void`. See file header. */
135
137
  tombstoneRedirect(requiredArgs: {
136
138
  id: string;
137
139
  }): Promise<AdminRedirect>;
@@ -18,12 +18,14 @@
18
18
  * has no `slug` field at all) — kept here because the union is OPEN and a different host may
19
19
  * legitimately implement `regex` even though the reference implementation refuses it.
20
20
  *
21
- * ## `tombstoneRedirect` is a soft delete
22
- *
23
- * Mirrors `AdminIntegrationsPort.deleteIntegrationSubscription`, not `AdminMediaPort.deleteMedia`:
24
- * the rule's `status` flips to `"disabled"` and the row is retained (audit trail, still readable
25
- * via `listRedirects`/`getRedirect`), never hard-purged. The method resolves to the updated record
26
- * (200-style), not `void` — a panel can render the new state without a re-fetch.
21
+ * ## `tombstoneRedirect` moves the rule to the Trash
22
+ *
23
+ * The rule's `status` flips to `"disabled"` and the row is retained (audit trail, still readable
24
+ * via `getRedirect`), never hard-purged here — but it LEAVES `listRedirects` (Bug B, 2026-10-22):
25
+ * a removed rule is shown only in the host's Trash, unlike a rule merely toggled off, which stays
26
+ * listed as `"disabled"`. Removing a toggled-off rule moves it to the Trash too. A pure
27
+ * `updateRedirect({ status: "active" })` (or the host's Trash restore) brings it back to the list.
28
+ * The method resolves to the updated record (200-style), not `void`.
27
29
  *
28
30
  * ## `source: "auto_slug_change"` is read-only provenance
29
31
  *
@@ -46,8 +46,10 @@ export function adminHref({ routePath }, { base = DEFAULT_ADMIN_BASE } = {}) {
46
46
  export function currentRoutePath({ pathname }, { base = DEFAULT_ADMIN_BASE } = {}) {
47
47
  if (pathname === base)
48
48
  return '/';
49
+ // The slice keeps the `/` the `startsWith` check just matched, so it is never empty and needs no
50
+ // `'/'` fallback.
49
51
  if (pathname.startsWith(`${base}/`))
50
- return pathname.slice(base.length) || '/';
52
+ return pathname.slice(base.length);
51
53
  // Reached only if the app is mounted somewhere unexpected; treat the whole path as the route
52
54
  // rather than silently returning the dashboard, so the mismatch is visible in the URL.
53
55
  return pathname || '/';
@@ -63,7 +65,8 @@ export function currentRoutePath({ pathname }, { base = DEFAULT_ADMIN_BASE } = {
63
65
  */
64
66
  export function matchRoute({ routePath, panels }) {
65
67
  const [rawPath, rawQuery] = routePath.split('?');
66
- const segments = (rawPath ?? '').split('/').filter(Boolean);
68
+ // `split` with a string separator always returns at least one element, so the path part exists.
69
+ const segments = rawPath.split('/').filter(Boolean);
67
70
  const query = new URLSearchParams(rawQuery ?? '');
68
71
  const miss = (panelId) => ({ panelId, view: null, params: {}, query });
69
72
  // Root is the dashboard by convention, and `dashboard` need not be registered for `/` to
@@ -99,8 +102,10 @@ function matchPattern(pattern, segments) {
99
102
  return null;
100
103
  const params = {};
101
104
  for (let i = 0; i < patternSegments.length; i += 1) {
102
- const patternSegment = patternSegments[i] ?? '';
103
- const segment = segments[i] ?? '';
105
+ // Both indexes exist: the loop is bounded by `patternSegments.length`, which the length check
106
+ // above made equal to `segments.length`.
107
+ const patternSegment = patternSegments[i];
108
+ const segment = segments[i];
104
109
  if (patternSegment.startsWith(':')) {
105
110
  params[patternSegment.slice(1)] = segment;
106
111
  continue;
@@ -18,16 +18,21 @@ function unreachableApiMessage({ status } = {}) {
18
18
  return `cannot reach the API${detail} — is the server running?`;
19
19
  }
20
20
  /** Timeout DOMExceptions can come from another realm; instanceof Error alone is unreliable. */
21
+ function errorName(cause) {
22
+ return cause && typeof cause === 'object' && 'name' in cause ? cause.name : undefined;
23
+ }
24
+ function requestTimeoutError({ cause, timeoutMs }, _optional = {}) {
25
+ const message = `the API did not respond within ${timeoutMs / 1000}s — the browser may be out of free connections for this origin (try closing other admin tabs) or the server is unresponsive`;
26
+ return new AdminApiError({ message, status: 0 }, {
27
+ code: 'REQUEST_TIMEOUT', body: { cause: cause instanceof Error ? cause.message : String(cause) },
28
+ });
29
+ }
21
30
  function throwTranslatedFetchFailure({ cause, timeoutMs }, { isPageUnloading } = {}) {
22
- const name = cause && typeof cause === 'object' && 'name' in cause ? cause.name : undefined;
31
+ const name = errorName(cause);
23
32
  if (name === 'AbortError')
24
33
  throw cause;
25
- if (name === 'TimeoutError') {
26
- const message = `the API did not respond within ${timeoutMs / 1000}s — the browser may be out of free connections for this origin (try closing other admin tabs) or the server is unresponsive`;
27
- throw new AdminApiError({ message, status: 0 }, {
28
- code: 'REQUEST_TIMEOUT', body: { cause: cause instanceof Error ? cause.message : String(cause) },
29
- });
30
- }
34
+ if (name === 'TimeoutError')
35
+ throw requestTimeoutError({ cause, timeoutMs });
31
36
  // Only fetch's TypeError proves a network failure. Adapter bugs retain their original type.
32
37
  if (!(cause instanceof TypeError))
33
38
  throw cause;
@@ -37,6 +42,26 @@ function throwTranslatedFetchFailure({ cause, timeoutMs }, { isPageUnloading } =
37
42
  code: 'API_UNREACHABLE', body: { cause: cause.message },
38
43
  });
39
44
  }
45
+ /**
46
+ * Headers normalizes names and accepts every HeadersInit form; object spreads lose
47
+ * Headers instances and treat tuple arrays as numeric keys instead of header entries.
48
+ * Later sources win: the JSON default, then the transport's own, then the call's.
49
+ */
50
+ function mergeHeaders(sources) {
51
+ const headers = new Headers({ 'Content-Type': 'application/json' });
52
+ for (const source of sources)
53
+ new Headers(source).forEach((value, name) => headers.set(name, value));
54
+ return headers;
55
+ }
56
+ /** The error a non-2xx response throws, given its parsed (or unparseable) body. */
57
+ function responseError({ res, body, wasUnparseable }, _optional = {}) {
58
+ // Valid JSON proves the application answered. Only unparseable 5xx responses indicate
59
+ // an unreachable upstream or a server crash outside the application's JSON envelope.
60
+ const noAppEnvelope = wasUnparseable && res.status >= 500;
61
+ const fallback = noAppEnvelope ? unreachableApiMessage({ status: res.status }) : `request failed (${res.status})`;
62
+ const serverCode = typeof body?.code === 'string' ? body.code : undefined;
63
+ return new AdminApiError({ message: String(body?.error ?? fallback), status: res.status }, { code: noAppEnvelope ? 'API_UNREACHABLE' : serverCode, body });
64
+ }
40
65
  /**
41
66
  * The standard `fetch`-backed transport.
42
67
  *
@@ -44,14 +69,10 @@ function throwTranslatedFetchFailure({ cause, timeoutMs }, { isPageUnloading } =
44
69
  * server's `error` string, HTTP status, canonical `code` when present, and the raw parsed body.
45
70
  */
46
71
  export function createHttpTransport({ baseUrl, fetch: doFetch }, { headers: baseHeaders, credentials = 'same-origin', timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, isPageUnloading } = {}) {
72
+ const unloading = isPageUnloading ? { isPageUnloading } : {};
47
73
  return {
48
74
  async request({ path }, options = {}) {
49
75
  const { onOk, ...init } = options;
50
- // Headers normalizes names and accepts every HeadersInit form; object spreads lose
51
- // Headers instances and treat tuple arrays as numeric keys instead of header entries.
52
- const headers = new Headers({ 'Content-Type': 'application/json' });
53
- new Headers(baseHeaders).forEach((value, name) => headers.set(name, value));
54
- new Headers(init.headers).forEach((value, name) => headers.set(name, value));
55
76
  // An explicit signal owns cancellation and its deadline; never replace it with ours.
56
77
  const signal = init.signal ?? AbortSignal.timeout(timeoutMs);
57
78
  const res = await doFetch({ url: `${baseUrl}${path}` }, {
@@ -61,19 +82,15 @@ export function createHttpTransport({ baseUrl, fetch: doFetch }, { headers: base
61
82
  // Assign last so a per-call header overrides both the JSON default and the transport's
62
83
  // own. The reference implementation's original spread `...init` last, which meant a call passing `headers` lost
63
84
  // the `Content-Type` default rather than merging with it.
64
- headers,
65
- }).catch((cause) => throwTranslatedFetchFailure({ cause, timeoutMs }, isPageUnloading ? { isPageUnloading } : {}));
85
+ headers: mergeHeaders([baseHeaders, init.headers]),
86
+ }).catch((cause) => throwTranslatedFetchFailure({ cause, timeoutMs }, unloading));
66
87
  // A 204 or an empty body is not a JSON parse failure worth surfacing — it is a successful
67
88
  // mutation with nothing to return. Same `.catch(() => ({}))` as the original.
68
89
  const parsed = await res.json().catch(() => UNPARSEABLE_BODY);
69
90
  const wasUnparseable = parsed === UNPARSEABLE_BODY;
70
91
  const body = (wasUnparseable ? {} : parsed);
71
- if (!res.ok) {
72
- // Valid JSON proves the application answered. Only unparseable 5xx responses indicate
73
- // an unreachable upstream or a server crash outside the application's JSON envelope.
74
- const noAppEnvelope = wasUnparseable && res.status >= 500;
75
- throw new AdminApiError({ message: String(body?.error ?? (noAppEnvelope ? unreachableApiMessage({ status: res.status }) : `request failed (${res.status})`)), status: res.status }, { code: noAppEnvelope ? 'API_UNREACHABLE' : typeof body?.code === 'string' ? body.code : undefined, body });
76
- }
92
+ if (!res.ok)
93
+ throw responseError({ res, body, wasUnparseable });
77
94
  onOk?.({ response: res });
78
95
  return body;
79
96
  },
@@ -1,21 +1,38 @@
1
1
  import { AdminApiError } from '../../core/transport/errors.js';
2
+ // The contract excludes slug; runtime callers cannot smuggle it through a cast either.
3
+ const PATCHABLE_KEYS = ['name', 'fields', 'notify', 'status', 'mode', 'html'];
4
+ /** The allowlisted patch keys the caller actually supplied (an explicit `undefined` is not a value). */
5
+ function suppliedPatch(patch) {
6
+ return Object.fromEntries(PATCHABLE_KEYS.filter(key => patch[key] !== undefined).map(key => [key, patch[key]]));
7
+ }
8
+ /** The core contract's add/edit-only invariant: a fields replacement must keep every current id. */
9
+ function assertNoFieldRemoved({ current, patch }) {
10
+ if (!patch.fields)
11
+ return;
12
+ const incomingIds = new Set(patch.fields.map(field => field.id));
13
+ if (current.fields.some(field => !incomingIds.has(field.id))) {
14
+ throw new AdminApiError({ message: 'existing form fields cannot be removed', status: 400 }, { code: 'FORMS_FIELD_VALIDATION_ERROR' });
15
+ }
16
+ }
2
17
  /** A scoped, deterministic in-memory API with the core contract's add/edit-only invariant. */
3
18
  export function createMemoryFormsApi(_required = {}, { forms = [], submissions = [], now = () => new Date(0).toISOString(), nextId } = {}) {
4
19
  const definitions = structuredClone([...forms]);
5
20
  const answers = structuredClone([...submissions]);
6
21
  let sequence = definitions.length;
7
- const missing = (id) => { throw new AdminApiError({ message: `form definition '${id}' was not found`, status: 404 }); };
22
+ const missing = (id, { kind = 'form definition' } = {}) => {
23
+ throw new AdminApiError({ message: `${kind} '${id}' was not found`, status: 404 });
24
+ };
8
25
  const find = (id) => definitions.find(form => form.slug === id) ?? definitions.find(form => form.id === id) ?? missing(id);
9
26
  const definitionId = (id) => definitions.find(form => form.slug === id)?.id ?? id;
10
27
  const findSubmission = (formId, id) => {
11
28
  const resolvedId = definitionId(formId);
12
- return answers.find(item => item.id === id && item.formDefinitionId === resolvedId && item.status !== 'trash') ?? missing(id);
29
+ return answers.find(item => item.id === id && item.formDefinitionId === resolvedId && item.status !== 'trash') ?? missing(id, { kind: 'form submission' });
13
30
  };
14
31
  const remove = (type, id) => {
15
32
  const rows = type === 'form' ? definitions : answers;
16
33
  const index = rows.findIndex(row => row.id === id);
17
34
  if (index < 0)
18
- missing(id);
35
+ missing(id, { kind: type === 'form' ? 'form definition' : 'form submission' });
19
36
  // The fixture retains submission payloads just as the generic Trash owner does. Definitions
20
37
  // keep their existing separate fixture projection; the core has no definition-delete method.
21
38
  if (type === 'form_submission')
@@ -42,14 +59,8 @@ export function createMemoryFormsApi(_required = {}, { forms = [], submissions =
42
59
  },
43
60
  async updateFormDefinition({ id }, patch = {}) {
44
61
  const current = definitions.find(form => form.id === id) ?? missing(id);
45
- const incomingIds = patch.fields ? new Set(patch.fields.map(field => field.id)) : null;
46
- if (incomingIds && current.fields.some(field => !incomingIds.has(field.id))) {
47
- throw new AdminApiError({ message: 'existing form fields cannot be removed', status: 400 }, { code: 'FORMS_FIELD_VALIDATION_ERROR' });
48
- }
49
- // The contract excludes slug; runtime callers cannot smuggle it through a cast either.
50
- const { name, fields, notify, status, mode, html } = patch;
51
- const applied = { ...(name !== undefined ? { name } : {}), ...(fields !== undefined ? { fields } : {}), ...(notify !== undefined ? { notify } : {}), ...(status !== undefined ? { status } : {}), ...(mode !== undefined ? { mode } : {}), ...(html !== undefined ? { html } : {}) };
52
- const updated = { ...current, ...structuredClone(applied), updatedAt: now() };
62
+ assertNoFieldRemoved({ current, patch });
63
+ const updated = { ...current, ...structuredClone(suppliedPatch(patch)), updatedAt: now() };
53
64
  definitions[definitions.indexOf(current)] = updated;
54
65
  return structuredClone(updated);
55
66
  },
@@ -3,15 +3,17 @@ export interface FormsConformanceResult {
3
3
  readonly passed: number;
4
4
  readonly failures: readonly string[];
5
5
  }
6
+ type SubmissionSeed = {
7
+ formId: string;
8
+ submissionId: string;
9
+ };
6
10
  /** Run on an isolated API: creates definitions and trashes seeded submissions.
7
11
  * The host provides a disposable seed so conformance never touches an operator's live data. */
8
12
  export declare function runFormsApiConformance({ api }: {
9
13
  api: AdminFormsPort;
10
14
  }, { slug, submission }?: {
11
15
  slug?: string;
12
- submission?: {
13
- formId: string;
14
- submissionId: string;
15
- };
16
+ submission?: SubmissionSeed;
16
17
  }): Promise<FormsConformanceResult>;
18
+ export {};
17
19
  //# sourceMappingURL=forms-api.conformance.d.ts.map