@tai42/studio-sdk 20.0.0 → 22.0.0

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 (143) hide show
  1. package/dist/components/chip-group.d.ts +42 -0
  2. package/dist/components/chip-group.d.ts.map +1 -0
  3. package/dist/components/chip-group.js +85 -0
  4. package/dist/components/chip-group.js.map +1 -0
  5. package/dist/components/components.css +16 -3
  6. package/dist/components/execution-key-picker.js.map +1 -1
  7. package/dist/components/extension-grouping.d.ts +8 -12
  8. package/dist/components/extension-grouping.d.ts.map +1 -1
  9. package/dist/components/extension-grouping.js +12 -15
  10. package/dist/components/extension-grouping.js.map +1 -1
  11. package/dist/components/extension-picker.d.ts +3 -4
  12. package/dist/components/extension-picker.d.ts.map +1 -1
  13. package/dist/components/extension-picker.js +10 -10
  14. package/dist/components/extension-picker.js.map +1 -1
  15. package/dist/components/fleet-report.d.ts +10 -5
  16. package/dist/components/fleet-report.d.ts.map +1 -1
  17. package/dist/components/fleet-report.js +20 -3
  18. package/dist/components/fleet-report.js.map +1 -1
  19. package/dist/components/json-tree-model.d.ts +19 -7
  20. package/dist/components/json-tree-model.d.ts.map +1 -1
  21. package/dist/components/json-tree-model.js +32 -8
  22. package/dist/components/json-tree-model.js.map +1 -1
  23. package/dist/components/json-tree.d.ts +10 -1
  24. package/dist/components/json-tree.d.ts.map +1 -1
  25. package/dist/components/json-tree.js +7 -4
  26. package/dist/components/json-tree.js.map +1 -1
  27. package/dist/components/select.d.ts +6 -0
  28. package/dist/components/select.d.ts.map +1 -1
  29. package/dist/components/select.js +3 -2
  30. package/dist/components/select.js.map +1 -1
  31. package/dist/components/served-media.d.ts +50 -4
  32. package/dist/components/served-media.d.ts.map +1 -1
  33. package/dist/components/served-media.js +69 -19
  34. package/dist/components/served-media.js.map +1 -1
  35. package/dist/env-markers.d.ts +25 -0
  36. package/dist/env-markers.d.ts.map +1 -0
  37. package/dist/env-markers.js +63 -0
  38. package/dist/env-markers.js.map +1 -0
  39. package/dist/hooks/interactions-stream.d.ts.map +1 -1
  40. package/dist/hooks/interactions-stream.js +9 -7
  41. package/dist/hooks/interactions-stream.js.map +1 -1
  42. package/dist/hooks/useApi.d.ts +2264 -912
  43. package/dist/hooks/useApi.d.ts.map +1 -1
  44. package/dist/hooks/useCapabilities.d.ts +5 -9
  45. package/dist/hooks/useCapabilities.d.ts.map +1 -1
  46. package/dist/hooks/useCapabilities.js +8 -33
  47. package/dist/hooks/useCapabilities.js.map +1 -1
  48. package/dist/host.d.ts +2 -0
  49. package/dist/host.d.ts.map +1 -1
  50. package/dist/host.js +8 -0
  51. package/dist/host.js.map +1 -1
  52. package/dist/index.d.ts +12 -6
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +5 -3
  55. package/dist/index.js.map +1 -1
  56. package/dist/plugin/coverage.d.ts +14 -0
  57. package/dist/plugin/coverage.d.ts.map +1 -0
  58. package/dist/plugin/coverage.js +14 -0
  59. package/dist/plugin/coverage.js.map +1 -0
  60. package/dist/plugin/registry.d.ts +11 -1
  61. package/dist/plugin/registry.d.ts.map +1 -1
  62. package/dist/plugin/registry.js +33 -4
  63. package/dist/plugin/registry.js.map +1 -1
  64. package/dist/plugin/types.d.ts +43 -1
  65. package/dist/plugin/types.d.ts.map +1 -1
  66. package/dist/schema-form/SchemaForm.d.ts +11 -1
  67. package/dist/schema-form/SchemaForm.d.ts.map +1 -1
  68. package/dist/schema-form/SchemaForm.js +2 -2
  69. package/dist/schema-form/SchemaForm.js.map +1 -1
  70. package/dist/schema-form/classify.d.ts.map +1 -1
  71. package/dist/schema-form/classify.js +54 -2
  72. package/dist/schema-form/classify.js.map +1 -1
  73. package/dist/schema-form/date-range-field.d.ts +44 -0
  74. package/dist/schema-form/date-range-field.d.ts.map +1 -0
  75. package/dist/schema-form/date-range-field.js +72 -0
  76. package/dist/schema-form/date-range-field.js.map +1 -0
  77. package/dist/schema-form/enum-field.d.ts +3 -1
  78. package/dist/schema-form/enum-field.d.ts.map +1 -1
  79. package/dist/schema-form/enum-field.js +8 -2
  80. package/dist/schema-form/enum-field.js.map +1 -1
  81. package/dist/schema-form/field-model.d.ts +20 -1
  82. package/dist/schema-form/field-model.d.ts.map +1 -1
  83. package/dist/schema-form/field-node.d.ts +10 -0
  84. package/dist/schema-form/field-node.d.ts.map +1 -1
  85. package/dist/schema-form/field-node.js +6 -3
  86. package/dist/schema-form/field-node.js.map +1 -1
  87. package/dist/schema-form/index.d.ts +3 -1
  88. package/dist/schema-form/index.d.ts.map +1 -1
  89. package/dist/schema-form/index.js +1 -0
  90. package/dist/schema-form/index.js.map +1 -1
  91. package/dist/schema-form/multiselect-field.d.ts.map +1 -1
  92. package/dist/schema-form/multiselect-field.js +11 -0
  93. package/dist/schema-form/multiselect-field.js.map +1 -1
  94. package/dist/schema-form/object-fields.d.ts +9 -1
  95. package/dist/schema-form/object-fields.d.ts.map +1 -1
  96. package/dist/schema-form/object-fields.js +16 -3
  97. package/dist/schema-form/object-fields.js.map +1 -1
  98. package/dist/schema-form/string-field.d.ts +6 -1
  99. package/dist/schema-form/string-field.d.ts.map +1 -1
  100. package/dist/schema-form/string-field.js +42 -8
  101. package/dist/schema-form/string-field.js.map +1 -1
  102. package/dist/schema-form/types.d.ts +36 -0
  103. package/dist/schema-form/types.d.ts.map +1 -1
  104. package/dist/state-binding/UpdateRow.d.ts +2 -2
  105. package/dist/state-binding/UpdateRow.js +1 -1
  106. package/dist/state-binding/UpdateRow.js.map +1 -1
  107. package/dist/state-binding/adapter.d.ts +12 -8
  108. package/dist/state-binding/adapter.d.ts.map +1 -1
  109. package/dist/state-binding/adapter.js +30 -13
  110. package/dist/state-binding/adapter.js.map +1 -1
  111. package/package.json +5 -4
  112. package/src/components/chip-group.tsx +157 -0
  113. package/src/components/components.css +16 -3
  114. package/src/components/execution-key-picker.tsx +1 -1
  115. package/src/components/extension-grouping.ts +22 -22
  116. package/src/components/extension-picker.tsx +13 -14
  117. package/src/components/fleet-report.tsx +66 -7
  118. package/src/components/json-tree-model.ts +36 -8
  119. package/src/components/json-tree.tsx +24 -4
  120. package/src/components/select.tsx +11 -1
  121. package/src/components/served-media.tsx +150 -16
  122. package/src/env-markers.ts +70 -0
  123. package/src/hooks/interactions-stream.ts +9 -7
  124. package/src/hooks/useCapabilities.ts +8 -31
  125. package/src/host.ts +2 -0
  126. package/src/index.ts +22 -2
  127. package/src/plugin/coverage.ts +23 -0
  128. package/src/plugin/registry.ts +58 -3
  129. package/src/plugin/types.ts +45 -1
  130. package/src/schema-form/SchemaForm.tsx +10 -0
  131. package/src/schema-form/classify.ts +59 -3
  132. package/src/schema-form/date-range-field.tsx +167 -0
  133. package/src/schema-form/enum-field.tsx +20 -0
  134. package/src/schema-form/field-model.ts +24 -2
  135. package/src/schema-form/field-node.tsx +19 -0
  136. package/src/schema-form/index.ts +10 -1
  137. package/src/schema-form/multiselect-field.tsx +21 -0
  138. package/src/schema-form/object-fields.tsx +36 -2
  139. package/src/schema-form/string-field.tsx +72 -1
  140. package/src/schema-form/types.ts +38 -0
  141. package/src/state-binding/README.md +10 -7
  142. package/src/state-binding/UpdateRow.tsx +3 -3
  143. package/src/state-binding/adapter.ts +31 -16
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The bounded-traversal model behind `JsonTree`: container inspection, the paged
3
3
  * child slice, path identity, and the open-state computation for the depth
4
- * default, expand-all and collapse-all baselines. Pure — no React, no DOM.
4
+ * default, expand-all and collapse-all baselines and the caller's named paths.
5
+ * Pure — no React, no DOM.
5
6
  */
6
7
  import { isRecord } from '../guards';
7
8
 
@@ -71,8 +72,30 @@ export function childPath(parent: string, name: string): string {
71
72
  return parent === '' ? segment : `${parent}/${segment}`;
72
73
  }
73
74
 
74
- /** The default open state a call site's `defaultExpanded` selects. */
75
- export type Baseline = 'depth' | 'expanded' | 'collapsed';
75
+ /**
76
+ * The identities of every container a caller's named paths open: each path is a
77
+ * list of keys from the root (an array item named by its index), and the root,
78
+ * every container along the path and the one it ends at are all opened.
79
+ */
80
+ export function namedOpenPaths(paths: readonly (readonly string[])[]): ReadonlySet<string> {
81
+ const open = new Set<string>();
82
+ for (const segments of paths) {
83
+ let path = '';
84
+ open.add(path);
85
+ for (const segment of segments) {
86
+ path = childPath(path, segment);
87
+ open.add(path);
88
+ }
89
+ }
90
+ return open;
91
+ }
92
+
93
+ /**
94
+ * The open state the nodes start from: the three a call site's `defaultExpanded`
95
+ * selects (the depth-guarded default, expand-all, collapsed), and `closed`, the
96
+ * reader's collapse-all, which also shuts the caller's named paths.
97
+ */
98
+ export type Baseline = 'depth' | 'expanded' | 'collapsed' | 'closed';
76
99
 
77
100
  export function initialBaseline(defaultExpanded: boolean | undefined): Baseline {
78
101
  if (defaultExpanded === true) return 'expanded';
@@ -119,21 +142,26 @@ export function expandedOpenPaths(root: unknown): ReadonlySet<string> {
119
142
  }
120
143
 
121
144
  /**
122
- * Whether a node is open, from the baseline and the reader's own toggles. An
123
- * explicit toggle always wins; otherwise the baseline decides — the depth-guarded
124
- * default opens through {@link AUTO_EXPAND_DEPTH}, expand-all opens the budgeted
125
- * breadth-first set, collapse-all opens nothing.
145
+ * Whether a node is open, from the baseline, the caller's named paths and the
146
+ * reader's own toggles. An explicit toggle always wins; the reader's collapse-all
147
+ * opens nothing; otherwise a node on a named path is open beside any baseline a call
148
+ * site selects, and the baseline decides the rest — a collapsed baseline opens
149
+ * nothing else, the depth-guarded default opens through {@link AUTO_EXPAND_DEPTH},
150
+ * expand-all opens the budgeted breadth-first set.
126
151
  */
127
152
  export function computeOpen(
128
153
  baseline: Baseline,
129
154
  overrides: ReadonlyMap<string, boolean>,
130
155
  expandedPaths: ReadonlySet<string> | null,
156
+ namedPaths: ReadonlySet<string>,
131
157
  path: string,
132
158
  depth: number,
133
159
  ): boolean {
134
160
  const override = overrides.get(path);
135
161
  if (override !== undefined) return override;
136
- if (baseline === 'expanded') return expandedPaths?.has(path) ?? false;
162
+ if (baseline === 'closed') return false;
163
+ if (namedPaths.has(path)) return true;
137
164
  if (baseline === 'collapsed') return false;
165
+ if (baseline === 'expanded') return expandedPaths?.has(path) ?? false;
138
166
  return depth <= AUTO_EXPAND_DEPTH;
139
167
  }
@@ -12,6 +12,8 @@
12
12
  * renders no children. `defaultExpanded` overrides per call site.
13
13
  * - Expand-all opens breadth-first only until {@link AUTO_EXPAND_NODE_BUDGET} nodes
14
14
  * render and never past {@link MAX_AUTO_DEPTH}; a node beyond either opens on click.
15
+ * - `openPaths` opens exactly the containers along the paths a caller names, at any
16
+ * depth, beside the baseline; collapse-all and the reader's toggles close them.
15
17
  * - A container over {@link PAGE_SIZE} children renders one page plus a "show more".
16
18
  * - Copy-whole and copy-node write JSON to the clipboard; a refused write shows an alert.
17
19
  *
@@ -30,6 +32,7 @@ import {
30
32
  expandedOpenPaths,
31
33
  initialBaseline,
32
34
  isContainer,
35
+ namedOpenPaths,
33
36
  PAGE_SIZE,
34
37
  } from './json-tree-model';
35
38
  import { COPY_LABEL, CopyButton, JsonNode } from './json-tree-nodes';
@@ -44,6 +47,13 @@ export interface JsonTreeProps {
44
47
  * opens through {@link AUTO_EXPAND_DEPTH} and collapses everything below.
45
48
  */
46
49
  readonly defaultExpanded?: boolean;
50
+ /**
51
+ * Paths whose containers open beside the baseline, at any depth: each path lists
52
+ * the keys from the root (an array item by its index, as a string), and the root,
53
+ * every container along it and the one it ends at open. The reader's own toggle
54
+ * still wins, and collapse-all closes them like every other node.
55
+ */
56
+ readonly openPaths?: readonly (readonly string[])[];
47
57
  /** The region's accessible name, applied only while the pane actually scrolls. */
48
58
  readonly label?: string;
49
59
  }
@@ -51,7 +61,15 @@ export interface JsonTreeProps {
51
61
  /** The region's name when the caller supplies none. */
52
62
  const DEFAULT_LABEL = 'JSON';
53
63
 
54
- export function JsonTree({ data, defaultExpanded, label }: JsonTreeProps): ReactElement {
64
+ /** The paths a tree without `openPaths` opens beside its baseline. */
65
+ const NO_PATHS: readonly (readonly string[])[] = [];
66
+
67
+ export function JsonTree({
68
+ data,
69
+ defaultExpanded,
70
+ openPaths = NO_PATHS,
71
+ label,
72
+ }: JsonTreeProps): ReactElement {
55
73
  const [baseline, setBaseline] = useState<Baseline>(() => initialBaseline(defaultExpanded));
56
74
  const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(() => new Map());
57
75
 
@@ -76,10 +94,12 @@ export function JsonTree({ data, defaultExpanded, label }: JsonTreeProps): React
76
94
  [baseline, data],
77
95
  );
78
96
 
97
+ const namedPaths = useMemo(() => namedOpenPaths(openPaths), [openPaths]);
98
+
79
99
  const isOpen = useCallback(
80
100
  (path: string, depth: number): boolean =>
81
- computeOpen(baseline, overrides, expandedPaths, path, depth),
82
- [baseline, overrides, expandedPaths],
101
+ computeOpen(baseline, overrides, expandedPaths, namedPaths, path, depth),
102
+ [baseline, overrides, expandedPaths, namedPaths],
83
103
  );
84
104
 
85
105
  const setOpen = useCallback((path: string, open: boolean): void => {
@@ -107,7 +127,7 @@ export function JsonTree({ data, defaultExpanded, label }: JsonTreeProps): React
107
127
  setOverrides(new Map());
108
128
  };
109
129
  const collapseAll = (): void => {
110
- setBaseline('collapsed');
130
+ setBaseline('closed');
111
131
  setOverrides(new Map());
112
132
  };
113
133
 
@@ -42,6 +42,12 @@ interface SelectSharedProps {
42
42
  * the visible text out of the accessible name (WCAG 2.5.3).
43
43
  */
44
44
  readonly 'aria-label'?: string;
45
+ /**
46
+ * IDREFs of elements that describe the trigger. An enclosing `Field` owns entries
47
+ * in the same list (its description and error), so the two are concatenated,
48
+ * caller's first, exactly as the text inputs do.
49
+ */
50
+ readonly 'aria-describedby'?: string;
45
51
  }
46
52
 
47
53
  /** The FLAT listbox: one ungrouped run of options. */
@@ -93,8 +99,12 @@ export function Select(props: SelectArgs) {
93
99
  disabled,
94
100
  name,
95
101
  'aria-label': ariaLabel,
102
+ 'aria-describedby': ownDescribedBy,
96
103
  } = props;
97
104
  const field = useFieldControl();
105
+ const describedBy = [ownDescribedBy, field['aria-describedby']].filter(
106
+ (id): id is string => id !== undefined,
107
+ );
98
108
  // Untyped JavaScript is the only caller that reaches this. Rendering one list
99
109
  // and dropping the other would lose every entry in the discarded one silently,
100
110
  // and an empty listbox reads as "there is nothing to choose".
@@ -118,7 +128,7 @@ export function Select(props: SelectArgs) {
118
128
  // for>`, and `aria-label` outranks a native label — emitting both would
119
129
  // discard the visible text the field renders.
120
130
  aria-label={field.id === undefined ? ariaLabel : undefined}
121
- aria-describedby={field['aria-describedby']}
131
+ aria-describedby={describedBy.length > 0 ? describedBy.join(' ') : undefined}
122
132
  aria-invalid={field['aria-invalid']}
123
133
  >
124
134
  <RadixSelect.Value placeholder={placeholder} />
@@ -12,6 +12,7 @@
12
12
  * url is a loud blocked notice, its text React-escaped, never a live attribute.
13
13
  * There is no `dangerouslySetInnerHTML` here.
14
14
  */
15
+ import type { ServedConstants } from '@tai42/api-client';
15
16
  import type { CSSProperties, ReactNode } from 'react';
16
17
  import { useState } from 'react';
17
18
 
@@ -20,24 +21,29 @@ import { Badge } from './badge';
20
21
  import { isSafeHttpUrl } from './primitives';
21
22
 
22
23
  /**
23
- * The served-media route: media is stored by reference and served from the API
24
- * origin at `MEDIA_ROUTE_PREFIX + <id>`, where the id is 43 urlsafe-base64 chars
25
- * (32 random bytes). A record carries it as a RELATIVE url of exactly that shape —
26
- * the platform's own media reference, resolved to the API base at render time
24
+ * The served-media grammar the contract publishes, read from the injected client
25
+ * (`useApi().constants`): the served route prefix and the stored-media id pattern. Media is
26
+ * stored by reference and served from the API origin at `MEDIA_ROUTE_PREFIX + <id>`; a record
27
+ * carries it as a RELATIVE url of exactly that shape, resolved to the API base at render time
27
28
  * ({@link resolveMediaSrc}), not assumed same-origin as the SPA page.
28
29
  */
29
- const MEDIA_ROUTE_PREFIX = '/api/interactions/media/';
30
- const MEDIA_ID = /^[A-Za-z0-9_-]{43}$/;
30
+ export type ServedMediaGrammar = {
31
+ readonly [K in keyof Pick<ServedConstants, 'MEDIA_ROUTE_PREFIX' | 'MEDIA_ID_PATTERN'>]: string;
32
+ };
31
33
 
32
34
  /**
33
35
  * Whether `url` is the platform's own served-media reference: a relative
34
- * `MEDIA_ROUTE_PREFIX + <id>` with a well-formed 43-char urlsafe-base64 id. A served
36
+ * `MEDIA_ROUTE_PREFIX + <id>` whose id matches `MEDIA_ID_PATTERN` whole. A served
35
37
  * reference is same-origin to the API base ({@link resolveMediaSrc}) and leaks no page
36
38
  * context on load, so a sink with no per-element referrer control (`<audio>`/`<video>`,
37
39
  * where `referrerPolicy` is not a valid attribute) admits ONLY this form.
38
40
  */
39
- export function isServedMediaUrl(url: string): boolean {
40
- return url.startsWith(MEDIA_ROUTE_PREFIX) && MEDIA_ID.test(url.slice(MEDIA_ROUTE_PREFIX.length));
41
+ export function isServedMediaUrl(url: string, grammar: ServedMediaGrammar): boolean {
42
+ const prefix = grammar.MEDIA_ROUTE_PREFIX;
43
+ return (
44
+ url.startsWith(prefix) &&
45
+ new RegExp(`^${grammar.MEDIA_ID_PATTERN}$`).test(url.slice(prefix.length))
46
+ );
41
47
  }
42
48
 
43
49
  /**
@@ -55,9 +61,17 @@ export function isServedMediaUrl(url: string): boolean {
55
61
  * item. The gate keys on the reference form; {@link resolveMediaSrc} joins an
56
62
  * admitted reference to the API base for the actual load.
57
63
  */
58
- export function isRenderableMediaSrc(url: string, allowDataImage = false): boolean {
64
+ export function isRenderableMediaSrc(
65
+ url: string,
66
+ grammar: ServedMediaGrammar,
67
+ allowDataImage = false,
68
+ ): boolean {
59
69
  const isHttpsUrl = isSafeHttpUrl(url) && new URL(url).protocol === 'https:';
60
- return isHttpsUrl || (allowDataImage && url.startsWith('data:image/')) || isServedMediaUrl(url);
70
+ return (
71
+ isHttpsUrl ||
72
+ (allowDataImage && url.startsWith('data:image/')) ||
73
+ isServedMediaUrl(url, grammar)
74
+ );
61
75
  }
62
76
 
63
77
  /**
@@ -69,8 +83,8 @@ export function isRenderableMediaSrc(url: string, allowDataImage = false): boole
69
83
  * relative, which is correct; a configured base's trailing slash is stripped so the
70
84
  * join never double-slashes.
71
85
  */
72
- export function resolveMediaSrc(url: string, baseUrl: string): string {
73
- return isServedMediaUrl(url) ? `${baseUrl.replace(/\/+$/, '')}${url}` : url;
86
+ export function resolveMediaSrc(url: string, baseUrl: string, grammar: ServedMediaGrammar): string {
87
+ return isServedMediaUrl(url, grammar) ? `${baseUrl.replace(/\/+$/, '')}${url}` : url;
74
88
  }
75
89
 
76
90
  // -- styles ------------------------------------------------------------------
@@ -150,9 +164,9 @@ export function MediaImage({
150
164
  // url rendered at this same position gets a fresh load attempt instead of inheriting
151
165
  // a stale failure notice.
152
166
  const [failedUrl, setFailedUrl] = useState<string | null>(null);
153
- const { baseUrl } = useApi();
167
+ const { baseUrl, constants } = useApi();
154
168
 
155
- if (!isRenderableMediaSrc(url, allowDataImage)) {
169
+ if (!isRenderableMediaSrc(url, constants, allowDataImage)) {
156
170
  if (renderNotice !== undefined) return renderNotice('blocked', url);
157
171
  return (
158
172
  <div role="alert" data-testid="media-item-blocked" style={blockedStyle}>
@@ -175,7 +189,7 @@ export function MediaImage({
175
189
  return (
176
190
  <div style={itemStyle}>
177
191
  <img
178
- src={resolveMediaSrc(url, baseUrl)}
192
+ src={resolveMediaSrc(url, baseUrl, constants)}
179
193
  alt={caption ?? altFallback}
180
194
  referrerPolicy="no-referrer"
181
195
  style={imageStyle}
@@ -187,3 +201,123 @@ export function MediaImage({
187
201
  </div>
188
202
  );
189
203
  }
204
+
205
+ // -- document item -----------------------------------------------------------
206
+
207
+ const chipStyle: CSSProperties = {
208
+ display: 'flex',
209
+ alignItems: 'center',
210
+ gap: 'var(--tai-space-2)',
211
+ };
212
+
213
+ const labelStyle: CSSProperties = {
214
+ wordBreak: 'break-all',
215
+ };
216
+
217
+ export interface MediaDocumentProps {
218
+ readonly url: string;
219
+ /** The download label: the item's filename or caption, or the caller's default. */
220
+ readonly label: string;
221
+ /** Render the notice shown when the url fails the src gate. */
222
+ readonly renderNotice: (url: string) => ReactNode;
223
+ }
224
+
225
+ /**
226
+ * One document: a labelled download affordance. The served route sends
227
+ * `Content-Disposition: attachment` for documents, so the anchor `download`-hints and
228
+ * opens in a new tab; `rel="noreferrer"` stops the Studio URL leaking to the media
229
+ * host. A url that fails {@link isRenderableMediaSrc} is the caller's notice, never a
230
+ * live href.
231
+ */
232
+ export function MediaDocument({ url, label, renderNotice }: MediaDocumentProps): ReactNode {
233
+ const { baseUrl, constants } = useApi();
234
+ if (!isRenderableMediaSrc(url, constants)) return renderNotice(url);
235
+ return (
236
+ <div style={chipStyle} data-testid="media-document">
237
+ <Badge variant="neutral">Document</Badge>
238
+ <a
239
+ href={resolveMediaSrc(url, baseUrl, constants)}
240
+ target="_blank"
241
+ rel="noreferrer"
242
+ download
243
+ title={label}
244
+ style={labelStyle}
245
+ >
246
+ {label}
247
+ </a>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ // -- audio / video item ------------------------------------------------------
253
+
254
+ const audioStyle: CSSProperties = {
255
+ width: '100%',
256
+ };
257
+
258
+ const videoStyle: CSSProperties = {
259
+ maxWidth: '100%',
260
+ maxHeight: '320px',
261
+ width: 'auto',
262
+ height: 'auto',
263
+ borderRadius: 'var(--tai-radius-md)',
264
+ border: '1px solid var(--tai-color-border)',
265
+ };
266
+
267
+ export interface MediaPlayerProps {
268
+ readonly kind: 'audio' | 'video';
269
+ readonly url: string;
270
+ /** The player's accessible label. */
271
+ readonly label: string;
272
+ /**
273
+ * Render the notice shown when no player is displayed: `state` is `'blocked'` when
274
+ * the url is not a served reference and `'failed'` when it fails to LOAD.
275
+ */
276
+ readonly renderNotice: (state: 'blocked' | 'failed', url: string) => ReactNode;
277
+ }
278
+
279
+ /**
280
+ * A native `<audio>`/`<video controls>` player. `referrerPolicy` is not a valid
281
+ * attribute on `<audio>`/`<video>`, so a remote load has no per-element referrer
282
+ * control; the player therefore admits ONLY the platform's own served-media reference
283
+ * ({@link isServedMediaUrl}: same-origin, leaking no page URL to a media host), and any
284
+ * other url, a bare `https:` one included, is the caller's notice. The load-failure
285
+ * flag keys on the url that failed so a new url at the same position gets a fresh load.
286
+ */
287
+ export function MediaPlayer({ kind, url, label, renderNotice }: MediaPlayerProps): ReactNode {
288
+ const [failedUrl, setFailedUrl] = useState<string | null>(null);
289
+ const { baseUrl, constants } = useApi();
290
+
291
+ if (!isServedMediaUrl(url, constants)) return renderNotice('blocked', url);
292
+ if (failedUrl === url) return renderNotice('failed', url);
293
+
294
+ const src = resolveMediaSrc(url, baseUrl, constants);
295
+ const onError = (): void => {
296
+ setFailedUrl(url);
297
+ };
298
+ return (
299
+ <div style={itemStyle}>
300
+ {kind === 'audio' ? (
301
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- a media item carries no caption track
302
+ <audio
303
+ controls
304
+ src={src}
305
+ aria-label={label}
306
+ onError={onError}
307
+ style={audioStyle}
308
+ data-testid="media-audio"
309
+ />
310
+ ) : (
311
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- a media item carries no caption track
312
+ <video
313
+ controls
314
+ src={src}
315
+ aria-label={label}
316
+ onError={onError}
317
+ style={videoStyle}
318
+ data-testid="media-video"
319
+ />
320
+ )}
321
+ </div>
322
+ );
323
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The `!ENV ${VAR[:default]}` manifest marker grammar the platform reads: a string leaf
3
+ * that begins with the `!ENV ` prefix (the tag and exactly one space) references an
4
+ * environment variable by name, never its value. A variable name carries no `{`, `}` or
5
+ * `:`; an optional `:default` follows it.
6
+ */
7
+
8
+ /** The prefix that makes a string leaf an `!ENV` marker. */
9
+ export const ENV_MARKER_PREFIX = '!ENV ';
10
+
11
+ /** A leaf that is exactly one reference: group 1 the name, group 2 the optional default. */
12
+ const SINGLE_REF = /^!ENV \$\{([^}{:]+)(?::([^}]+))?\}$/;
13
+
14
+ /** Every reference inside a marker leaf's expression. */
15
+ const ANY_REF = /\$\{([^}{:]+)(?::[^}]+)?\}/g;
16
+
17
+ /** The reference of a single-reference marker leaf. */
18
+ export interface EnvRef {
19
+ readonly key: string;
20
+ /** The `:default` text (without the leading colon); absent for a bare reference. */
21
+ readonly default?: string;
22
+ }
23
+
24
+ /** The reference of `value` when it is exactly `!ENV ${KEY}` or `!ENV ${KEY:default}`, else `null`. */
25
+ export function parseEnvMarker(value: unknown): EnvRef | null {
26
+ if (typeof value !== 'string') return null;
27
+ const match = SINGLE_REF.exec(value);
28
+ if (match === null) return null;
29
+ const key = match[1] ?? '';
30
+ const defaultText = match[2];
31
+ return defaultText === undefined ? { key } : { key, default: defaultText };
32
+ }
33
+
34
+ /**
35
+ * The marker referencing `key`, carrying `defaultValue` when given. Throws when
36
+ * {@link parseEnvMarker} would not read the marker back as `(key, defaultValue)`: a name is
37
+ * non-empty and carries no `{`, `}` or `:`; a default is non-empty and carries no `}`.
38
+ */
39
+ export function formatEnvMarker(key: string, defaultValue?: string): string {
40
+ const marker =
41
+ defaultValue === undefined
42
+ ? `${ENV_MARKER_PREFIX}\${${key}}`
43
+ : `${ENV_MARKER_PREFIX}\${${key}:${defaultValue}}`;
44
+ const parsed = parseEnvMarker(marker);
45
+ if (parsed?.key !== key || parsed.default !== defaultValue) {
46
+ throw new Error(
47
+ `var ${JSON.stringify(key)} with default ${JSON.stringify(defaultValue)} cannot be written as an !ENV marker: a name is non-empty without '{', '}' or ':', a default is non-empty without '}'`,
48
+ );
49
+ }
50
+ return marker;
51
+ }
52
+
53
+ /** Every variable name any marker leaf anywhere under `value` references. */
54
+ export function collectEnvRefs(value: unknown): Set<string> {
55
+ const refs = new Set<string>();
56
+ const walk = (node: unknown): void => {
57
+ if (typeof node === 'string') {
58
+ if (!node.startsWith(ENV_MARKER_PREFIX)) return;
59
+ for (const match of node.slice(ENV_MARKER_PREFIX.length).matchAll(ANY_REF)) {
60
+ if (match[1] !== undefined) refs.add(match[1]);
61
+ }
62
+ } else if (Array.isArray(node)) {
63
+ for (const item of node) walk(item);
64
+ } else if (typeof node === 'object' && node !== null) {
65
+ for (const nested of Object.values(node)) walk(nested);
66
+ }
67
+ };
68
+ walk(value);
69
+ return refs;
70
+ }
@@ -75,7 +75,7 @@ function requiredFieldsValid(
75
75
 
76
76
  /**
77
77
  * The optional fields, each rejected only when PRESENT with the wrong type: an
78
- * absent value stays absent (or takes its schema default on assembly). `media`
78
+ * absent value takes its schema default (`null`, or `false` for `sensitive`) on assembly. `media`
79
79
  * items stay unknown by design — each is validated per item by the renderer.
80
80
  */
81
81
  function optionalFieldsValid(obj: Record<string, unknown>): boolean {
@@ -100,12 +100,14 @@ function buildInteraction(obj: Record<string, unknown> & RequiredFrameFields): I
100
100
  created_at: obj.created_at,
101
101
  timeout_at: obj.timeout_at,
102
102
  sensitive: typeof obj.sensitive === 'boolean' ? obj.sensitive : false,
103
- ...(typeof obj.server_verified === 'boolean' ? { server_verified: obj.server_verified } : {}),
104
- ...(typeof obj.channel === 'string' ? { channel: obj.channel } : {}),
105
- ...(typeof obj.recipient === 'string' ? { recipient: obj.recipient } : {}),
106
- ...(typeof obj.origin === 'string' ? { origin: obj.origin } : {}),
107
- ...(typeof obj.audience === 'string' ? { audience: obj.audience } : {}),
108
- ...(Array.isArray(obj.media) ? { media: obj.media } : {}),
103
+ server_verified: typeof obj.server_verified === 'boolean' ? obj.server_verified : null,
104
+ channel: typeof obj.channel === 'string' ? obj.channel : null,
105
+ recipient: typeof obj.recipient === 'string' ? obj.recipient : null,
106
+ origin: typeof obj.origin === 'string' ? obj.origin : null,
107
+ audience: typeof obj.audience === 'string' ? obj.audience : null,
108
+ // The items stay unvalidated here: the renderer validates each one, so one malformed
109
+ // item is a per-item notice rather than a dropped frame.
110
+ media: Array.isArray(obj.media) ? (obj.media as Interaction['media']) : null,
109
111
  };
110
112
  }
111
113
 
@@ -131,8 +131,8 @@ export function isFullProjection(projection: MeProjection): boolean {
131
131
 
132
132
  /**
133
133
  * Whether a concrete path is `prefix` itself or nested beneath it at a PATH-SEGMENT
134
- * boundary — the client mirror of the server's segment-aware coverage rule
135
- * (access_control/settings.py: `a == b or a.startswith(b + '/')`). Matching on a
134
+ * boundary — the client mirror of the server's segment-aware coverage rule, held to
135
+ * the platform's published path-coverage vectors by the shell's tests. Matching on a
136
136
  * bare `startsWith` would let a sibling route like `/api/tools-schema` satisfy the
137
137
  * `/api/tools` token; the boundary keeps them distinct.
138
138
  */
@@ -144,22 +144,18 @@ function pathUnderPrefix(path: string, prefix: string): boolean {
144
144
  * Whether the projection reaches ANY route under one of `prefixes` — the
145
145
  * declarative test behind nav filtering and plugin `requiredCapabilities`. A
146
146
  * concrete `routes[].path` covers a prefix when it is that prefix or a
147
- * segment-nested descendant of it; a dynamic `route_patterns[].pattern` covers a
148
- * prefix only when it is a simple anchored open-wildcard pattern (`^<literal>.*$`)
149
- * whose open literal overlaps the prefix at a segment boundary.
150
- *
151
- * Under-showing is safe (projection is UX; the server is the authority);
152
- * over-showing is the bug. So any non-anchored or otherwise complex pattern
153
- * (alternation, char class, bounded quantifier) is conservatively treated as NOT
154
- * covering — its matched set is not a clean prefix, so we never infer reach from
155
- * it.
147
+ * segment-nested descendant of it; a dynamic `route_patterns[]` entry covers a
148
+ * prefix through its served `open_prefix` — the literal the server rules every
149
+ * path starting with is reachable — when it overlaps the prefix at a segment
150
+ * boundary. A `null` open prefix (a pattern whose matched set is not a clean
151
+ * prefix) covers nothing by prefix: under-showing is safe, over-showing is the bug.
156
152
  */
157
153
  export function coversAnyRoute(projection: MeProjection, prefixes: readonly string[]): boolean {
158
154
  return prefixes.some(
159
155
  (prefix) =>
160
156
  projection.routes.some((route) => pathUnderPrefix(route.path, prefix)) ||
161
157
  projection.route_patterns.some((entry) => {
162
- const open = patternOpenPrefix(entry.pattern);
158
+ const open = entry.open_prefix;
163
159
  // The open literal L means "every path starting with L is reachable". That
164
160
  // set reaches something under `prefix` when the prefix itself lies in it
165
161
  // (`prefix.startsWith(open)`) or when the literal already dives under the
@@ -221,22 +217,3 @@ export function useCanWrite(path: string, method = 'POST'): boolean {
221
217
  const { state } = useCapabilities();
222
218
  return coversWrite(state, path, method);
223
219
  }
224
-
225
- const REGEX_METACHARACTERS = /[.*+?()[\]{}|\\^$]/;
226
-
227
- /**
228
- * The literal prefix of a simple anchored open-wildcard pattern (`^<literal>.*$`,
229
- * the `$` optional), or `null` when the pattern is not exactly that shape. A
230
- * returned literal `L` means "every path starting with `L` is reachable"; only
231
- * that open-wildcard form yields a sound prefix, so a bare literal, an
232
- * alternation, a char class, or any other construct declines (returns `null`).
233
- */
234
- function patternOpenPrefix(pattern: string): string | null {
235
- if (!pattern.startsWith('^')) return null;
236
- let body = pattern.slice(1);
237
- if (body.endsWith('$')) body = body.slice(0, -1);
238
- if (!body.endsWith('.*')) return null;
239
- body = body.slice(0, -2);
240
- if (body.length === 0 || REGEX_METACHARACTERS.test(body)) return null;
241
- return body;
242
- }
package/src/host.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * carries no way to forge, wipe, or enumerate the registry. Only the host bundle
6
6
  * imports this. See SECURITY.md for the trust boundary.
7
7
  */
8
+ export { contributionCovered } from './plugin/coverage';
8
9
  export type {
9
10
  LoadedPlugin,
10
11
  PluginContributionsSnapshot,
@@ -16,4 +17,5 @@ export {
16
17
  subscribePluginHost,
17
18
  usePluginContributions,
18
19
  } from './plugin/host-state';
20
+ export type { PluginLoadInputs } from './plugin/registry';
19
21
  export { getContributions, loadPlugin } from './plugin/registry';