@jini-ai/admin 0.6.12 → 0.6.15-rc.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.13
4
+
5
+ ### Patch Changes
6
+
7
+ - fddd084: Allow consent-enabled static HTML exports with native reopening controls and denied visitor defaults. Keep reopened preferences at the banner position and show dynamic-code help through the shared info-tip.
8
+ - 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
9
+ shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
10
+ (guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
11
+ `@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
12
+ filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
13
+ breaks are listed in `owner-store-to-server` and `guard-host-ports`.
14
+
3
15
  ## 0.6.12
4
16
 
5
17
  ### Patch Changes
@@ -6,4 +6,5 @@
6
6
  */
7
7
  export { NAVIGATION_EVENT, installInternalLinkInterceptor, navigate, readRoutePath, subscribeToRoute, } from './navigation.js';
8
8
  export { createAdminShellNavigation } from './shell-navigation.js';
9
+ export { createBrowserTelemetry, installBrowserErrorListeners, type BrowserTelemetry, type BrowserTelemetryDeps, type BrowserTelemetryPayload } from './telemetry.js';
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,5 @@
6
6
  */
7
7
  export { NAVIGATION_EVENT, installInternalLinkInterceptor, navigate, readRoutePath, subscribeToRoute, } from './navigation.js';
8
8
  export { createAdminShellNavigation } from './shell-navigation.js';
9
+ export { createBrowserTelemetry, installBrowserErrorListeners } from './telemetry.js';
9
10
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,45 @@
1
+ /** Browser telemetry over host-bound transport, timers and session state. Route names stay with the host. */
2
+ export type BrowserTelemetryPayload = {
3
+ type: "error";
4
+ message: string;
5
+ stack: string;
6
+ componentStack?: string | undefined;
7
+ route: string;
8
+ } | {
9
+ type: "pageview";
10
+ route: string;
11
+ };
12
+ export interface BrowserTelemetryDeps {
13
+ send(payload: BrowserTelemetryPayload): Promise<unknown>;
14
+ nowMs(): number;
15
+ setTimer(fn: () => void, ms: number): unknown;
16
+ clearTimer(handle: unknown): void;
17
+ }
18
+ export interface BrowserTelemetry {
19
+ /** Only a signed-in session can post; before sign-in everything is dropped. */
20
+ setSignedIn(signedIn: boolean): void;
21
+ /** The current route template; errors carry it, and a change sends a (debounced) page view. */
22
+ setRoute(route: string): void;
23
+ reportError(input: {
24
+ error: unknown;
25
+ componentStack?: string | undefined;
26
+ }): void;
27
+ }
28
+ /**
29
+ * Builds the telemetry engine over injected I/O so its dedupe, cap and debounce are testable.
30
+ * @complexity O(1) amortized per call; O(f) memory for at most {@link MAX_FINGERPRINTS} fingerprints.
31
+ */
32
+ export declare function createBrowserTelemetry(deps: BrowserTelemetryDeps, { maxErrorsPerMinute, pageviewDelayMs, initialRoute }?: {
33
+ maxErrorsPerMinute?: number | undefined;
34
+ pageviewDelayMs?: number | undefined;
35
+ initialRoute?: string | undefined;
36
+ }): BrowserTelemetry;
37
+ /**
38
+ * Window-level errors the React root never sees (event handlers, timers, promises).
39
+ * @returns Uninstall.
40
+ */
41
+ export declare function installBrowserErrorListeners({ target, telemetry }: {
42
+ target: Pick<Window, "addEventListener" | "removeEventListener">;
43
+ telemetry: Pick<BrowserTelemetry, "reportError">;
44
+ }, _options?: {}): () => void;
45
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1,108 @@
1
+ /** Mirrors the server's caps (`browser-telemetry.ts`): longer text is cut here, not refused there. */
2
+ const CAPS = { message: 1_000, stack: 8_000, componentStack: 4_000, route: 200 };
3
+ const WINDOW_MS = 60_000;
4
+ const DEDUPE_MS = 5 * 60_000;
5
+ const MAX_FINGERPRINTS = 200;
6
+ /** Message of an Error or a thrown string; any other thrown value is named by type only, never dumped. */
7
+ function messageOf(error) {
8
+ if (error instanceof Error)
9
+ return error.message || error.name;
10
+ if (typeof error === "string")
11
+ return error;
12
+ return `Non-Error thrown (${error === null ? "null" : typeof error})`;
13
+ }
14
+ function errorPayload({ error, componentStack, route }) {
15
+ const stack = error instanceof Error && typeof error.stack === "string" ? error.stack : "";
16
+ const cs = componentStack ? componentStack.slice(0, CAPS.componentStack) : "";
17
+ return { type: "error", message: messageOf(error).slice(0, CAPS.message), stack: stack.slice(0, CAPS.stack), ...(cs ? { componentStack: cs } : {}), route };
18
+ }
19
+ /** Message + first stack frame: the same bug from the same place is one report, not hundreds. */
20
+ function fingerprintOf(payload) {
21
+ const frame = payload.stack.split("\n").find((line) => /\bat\b|@/.test(line)) ?? "";
22
+ return `${payload.message}|${frame.trim()}`;
23
+ }
24
+ /**
25
+ * Builds the telemetry engine over injected I/O so its dedupe, cap and debounce are testable.
26
+ * @complexity O(1) amortized per call; O(f) memory for at most {@link MAX_FINGERPRINTS} fingerprints.
27
+ */
28
+ export function createBrowserTelemetry(deps, { maxErrorsPerMinute = 10, pageviewDelayMs = 1_000, initialRoute = "" } = {}) {
29
+ let signedIn = false;
30
+ let route = initialRoute;
31
+ let lastPageview = null;
32
+ let timer = null;
33
+ let sentAt = [];
34
+ const seen = new Map();
35
+ const post = (payload) => {
36
+ try {
37
+ void deps.send(payload).catch(() => { });
38
+ }
39
+ catch { /* never throws */ }
40
+ };
41
+ const underCap = (now) => {
42
+ sentAt = sentAt.filter((at) => now - at < WINDOW_MS);
43
+ return sentAt.length < maxErrorsPerMinute;
44
+ };
45
+ const isRepeat = (fingerprint, now) => {
46
+ const last = seen.get(fingerprint);
47
+ if (last !== undefined && now - last < DEDUPE_MS)
48
+ return true;
49
+ seen.delete(fingerprint);
50
+ if (seen.size >= MAX_FINGERPRINTS)
51
+ seen.delete(seen.keys().next().value);
52
+ seen.set(fingerprint, now);
53
+ return false;
54
+ };
55
+ const flushPageview = () => {
56
+ timer = null;
57
+ if (!signedIn || route === lastPageview)
58
+ return;
59
+ lastPageview = route;
60
+ post({ type: "pageview", route });
61
+ };
62
+ return {
63
+ setSignedIn(next) { signedIn = next; if (!next)
64
+ lastPageview = null; },
65
+ setRoute(next) {
66
+ route = next.slice(0, CAPS.route);
67
+ try {
68
+ if (timer !== null)
69
+ deps.clearTimer(timer);
70
+ timer = deps.setTimer(flushPageview, pageviewDelayMs);
71
+ }
72
+ catch { /* never throws */ }
73
+ },
74
+ reportError({ error, componentStack }) {
75
+ try {
76
+ if (!signedIn)
77
+ return;
78
+ const payload = errorPayload({ error, componentStack, route });
79
+ const now = deps.nowMs();
80
+ if (isRepeat(fingerprintOf(payload), now) || !underCap(now))
81
+ return;
82
+ sentAt.push(now);
83
+ post(payload);
84
+ }
85
+ catch { /* never throws */ }
86
+ },
87
+ };
88
+ }
89
+ /**
90
+ * Window-level errors the React root never sees (event handlers, timers, promises).
91
+ * @returns Uninstall.
92
+ */
93
+ export function installBrowserErrorListeners({ target, telemetry }, _options = {}) {
94
+ const onError = (event) => {
95
+ // A cross-origin "Script error." carries nothing usable; skip it rather than report noise.
96
+ if (event.error === undefined || event.error === null)
97
+ return;
98
+ telemetry.reportError({ error: event.error });
99
+ };
100
+ const onRejection = (event) => telemetry.reportError({ error: event.reason });
101
+ target.addEventListener("error", onError);
102
+ target.addEventListener("unhandledrejection", onRejection);
103
+ return () => {
104
+ target.removeEventListener("error", onError);
105
+ target.removeEventListener("unhandledrejection", onRejection);
106
+ };
107
+ }
108
+ //# sourceMappingURL=telemetry.js.map
@@ -1,5 +1,5 @@
1
1
  import { createContext, createElement, useContext, useCallback, useEffect, useMemo, useRef } from 'react';
2
- import { useCachedLoader, useFetchQuery, useInvalidate } from '@jini-ai/ui/fetch-query';
2
+ import { useCachedLoader, useFetchQuery, useInvalidate, useFetchQueryScope } from '@jini-ai/ui/fetch-query';
3
3
  import { AdminConfigError } from '../../../core/module/create-admin.js';
4
4
  import { useController } from '../../../core/react/use-controller.js';
5
5
  import { createCommentQueueController } from '../../controllers/comment-queue.controller.js';
@@ -18,19 +18,6 @@ const CommentQueueScopeContext = createContext(null);
18
18
  export function CommentQueueScopeProvider({ scope, children }) {
19
19
  return createElement(CommentQueueScopeContext.Provider, { value: scope }, children);
20
20
  }
21
- // Weak API keys preserve sharing between consumers without retaining APIs from previous sessions.
22
- // Explicit workspace/principal keys below also isolate hosts that retain one API across sessions.
23
- const queueScopes = new WeakMap();
24
- let nextQueueScope = 0;
25
- /** Allocate the API portion of a namespace; workspace/principal identity is keyed separately. */
26
- function queueScope({ api }, _optional = {}) {
27
- let scope = queueScopes.get(api);
28
- if (scope === undefined) {
29
- scope = ++nextQueueScope;
30
- queueScopes.set(api, scope);
31
- }
32
- return scope;
33
- }
34
21
  /** Preserve the hook's loading defaults while no controller from the current scope is attached. */
35
22
  function queueDisplay({ snapshot }, _optional = {}) {
36
23
  const { status, items, nextCursor, error, loadingMore, pendingPurge } = snapshot ?? BEFORE_FIRST_SNAPSHOT;
@@ -73,7 +60,10 @@ function useCursorPageLoader({ api, queueRoot, status, cursor }, _optional = {})
73
60
  */
74
61
  export function useCommentQueue({ api, scope, permissions = NO_GRANTS }, { t = englishComments, events } = {}) {
75
62
  const invalidate = useInvalidate();
76
- const queueRoot = useMemo(() => [...KEYS.queueRoot, queueScope({ api }), scope.workspaceId, scope.principalId], [api, scope.workspaceId, scope.principalId]);
63
+ // Weak API keys preserve sharing between consumers without retaining APIs from previous sessions.
64
+ // Explicit workspace/principal keys below also isolate hosts that retain one API across sessions.
65
+ const apiScope = useFetchQueryScope({ owner: api });
66
+ const queueRoot = useMemo(() => [...KEYS.queueRoot, apiScope, scope.workspaceId, scope.principalId], [apiScope, scope.workspaceId, scope.principalId]);
77
67
  const invalidateQueue = useCallback(() => invalidate({ key: queueRoot }), [invalidate, queueRoot]);
78
68
  const loadPageRef = useRef(null);
79
69
  const { controller: attachedController, snapshot: attachedSnapshot } = useController({
@@ -20,8 +20,12 @@ export interface ConsentFieldsProps {
20
20
  /** Host max lengths per text field; the defaults are the engine's loosest bounds. */
21
21
  limits?: Readonly<Partial<Record<TextKey | 'policyVersion', number>>> | undefined;
22
22
  }
23
- /** Banner copy and its links. Text is always plain text; the engine never renders it as HTML. */
24
- export declare function ConsentBannerFields({ value, onChange, disabled, problems, dictionary, limits }: ConsentFieldsProps): import("react").JSX.Element;
23
+ /** Banner copy and its links. Optional host modes report only a mode patch; the host's independent
24
+ * enabled switch stays authoritative. Text is always plain text.
25
+ * @example <ConsentBannerFields value={value} onChange={onChange} bannerModes={['jini', 'headless', 'off']} /> */
26
+ export declare function ConsentBannerFields({ value, onChange, disabled, problems, dictionary, limits, bannerModes }: ConsentFieldsProps & {
27
+ bannerModes?: readonly NonNullable<ConsentSettingsView['bannerMode']>[] | undefined;
28
+ }): import("react").JSX.Element;
25
29
  /** On/off, script policy, region behavior and the version that re-asks visitors when it changes. */
26
30
  export declare function ConsentRulesFields({ value, onChange, disabled, problems, dictionary, limits, regionModes }: ConsentFieldsProps & {
27
31
  regionModes?: readonly ConsentSettingsView['regionMode'][];
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import { defaultAdminTheme } from '@jini-ai/ui/theme';
4
+ import { InfoTip } from '@jini-ai/ui/admin-widgets';
4
5
  import { pixelInputValue, problemsFor, useConsentSettingsPanel, usePolicyVersionInput } from './settings-panel.hooks.js';
5
6
  const BANNER_FIELDS = [
6
7
  { key: 'title', label: 'Banner title' }, { key: 'message', label: 'Banner message', multiline: true },
@@ -8,6 +9,7 @@ const BANNER_FIELDS = [
8
9
  { key: 'preferencesLabel', label: 'Preferences label' }, { key: 'privacyPolicyUrl', label: 'Privacy policy page URL' },
9
10
  { key: 'secondaryLinkLabel', label: 'Second link text' }, { key: 'secondaryLinkUrl', label: 'Second link URL' },
10
11
  ];
12
+ const BANNER_MODE_LABELS = { jini: 'Jini banner', headless: 'My own', off: 'None' };
11
13
  const REGION_LABELS = {
12
14
  auto: 'Automatic (unknown region: opt-in)', 'opt-in': 'Opt-in everywhere (EU / UK)', 'opt-out': 'Opt-out everywhere (California)',
13
15
  };
@@ -38,11 +40,21 @@ function Field({ label, field, problems, children }) {
38
40
  const attributes = own.length ? { id, 'aria-invalid': true, 'aria-describedby': `${id}-problems` } : { id };
39
41
  return _jsxs("div", { className: "jini-consent-settings-field", children: [_jsx("label", { htmlFor: id, className: "jini-field-label", children: label }), children(attributes), _jsx(FieldProblems, { id: `${id}-problems`, problems: own })] });
40
42
  }
41
- /** Banner copy and its links. Text is always plain text; the engine never renders it as HTML. */
42
- export function ConsentBannerFields({ value, onChange, disabled = false, problems, dictionary, limits = {} }) {
43
+ /** Host-supplied banner modes. Native radios provide keyboard selection; the hint keeps its space
44
+ * while hidden, and the parent fieldset supplies the disabled state. */
45
+ function BannerModeFields({ value, onChange, dictionary, modes }) {
46
+ const id = useId();
47
+ const t = translator(dictionary);
48
+ const handlers = useConsentSettingsPanel({ value, onChange });
49
+ return _jsxs("fieldset", { className: "jini-consent-banner-modes", "aria-describedby": value.bannerMode === 'headless' ? `${id}-mode-hint` : undefined, children: [_jsx("legend", { children: t('Banner mode') }), _jsx("div", { className: "jini-consent-banner-options", children: modes.map(mode => _jsxs("label", { children: [_jsx("input", { type: "radio", name: `${id}-banner-mode`, value: mode, checked: (value.bannerMode ?? 'jini') === mode, onChange: handlers.bannerMode({ mode }) }), _jsx("span", { children: t(BANNER_MODE_LABELS[mode]) })] }, mode)) }), _jsx("p", { id: `${id}-mode-hint`, className: "jini-hint jini-consent-banner-mode-hint", style: { visibility: value.bannerMode === 'headless' ? 'visible' : 'hidden' }, children: t('Your banner calls window.jiniConsent.set(...)') })] });
50
+ }
51
+ /** Banner copy and its links. Optional host modes report only a mode patch; the host's independent
52
+ * enabled switch stays authoritative. Text is always plain text.
53
+ * @example <ConsentBannerFields value={value} onChange={onChange} bannerModes={['jini', 'headless', 'off']} /> */
54
+ export function ConsentBannerFields({ value, onChange, disabled = false, problems, dictionary, limits = {}, bannerModes }) {
43
55
  const t = translator(dictionary);
44
56
  const handlers = useConsentSettingsPanel({ value, onChange });
45
- return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Banner text') }), BANNER_FIELDS.map(({ key, label, multiline }) => _jsx(Field, { label: t(label), field: key, problems: problems, children: aria => multiline
57
+ return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Banner text') }), bannerModes && _jsx(BannerModeFields, { value: value, onChange: onChange, dictionary: dictionary, modes: bannerModes }), BANNER_FIELDS.map(({ key, label, multiline }) => _jsx(Field, { label: t(label), field: key, problems: problems, children: aria => multiline
46
58
  ? _jsx("textarea", { className: "jini-input", value: value[key] ?? '', maxLength: limits[key] ?? 2000, rows: 3, onChange: handlers.text(key), ...aria })
47
59
  : _jsx("input", { className: "jini-input", type: "text", value: value[key] ?? '', maxLength: limits[key] ?? 2000, onChange: handlers.text(key), ...aria }) }, key)), _jsx("p", { className: "jini-hint", children: t('Link a published privacy page, such as /privacy. The second link is optional.') })] });
48
60
  }
@@ -52,7 +64,7 @@ export function ConsentRulesFields({ value, onChange, disabled = false, problems
52
64
  const t = translator(dictionary);
53
65
  const handlers = useConsentSettingsPanel({ value, onChange });
54
66
  const modes = regionModes.includes(value.regionMode) ? regionModes : [value.regionMode, ...regionModes];
55
- return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Rules') }), _jsxs("button", { type: "button", className: value.enabled ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.enabled, "aria-labelledby": `${id}-enabled`, "aria-describedby": `${id}-hint`, onClick: handlers.toggle, children: [_jsxs("span", { className: "jini-toggle-row-text", children: [_jsx("span", { id: `${id}-enabled`, className: "jini-toggle-row-label", children: t('Enable cookie consent on this site') }), _jsx("span", { id: `${id}-hint`, className: "jini-toggle-row-hint", children: t('Applies to published site pages.') })] }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), value.enabled && _jsxs("button", { type: "button", className: value.allowUnsafeEval === true ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.allowUnsafeEval === true, "aria-labelledby": `${id}-eval`, "aria-describedby": `${id}-eval-hint`, onClick: handlers.toggleUnsafeEval, children: [_jsxs("span", { className: "jini-toggle-row-text", children: [_jsx("span", { id: `${id}-eval`, className: "jini-toggle-row-label", children: t('Allow dynamic code (unsafe-eval)') }), _jsx("span", { id: `${id}-eval-hint`, className: "jini-toggle-row-hint", children: t("Some themes need this. It weakens the site's script protection. Optional scripts still wait for consent.") })] }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), _jsx(Field, { label: t('Region mode'), field: "regionMode", problems: problems, children: aria => _jsx("select", { className: "jini-input", value: value.regionMode, onChange: handlers.region, ...aria, children: modes.map(mode => _jsx("option", { value: mode, children: t(REGION_LABELS[mode]) }, mode)) }) }), _jsx("p", { className: "jini-hint", children: t('California permits optional categories until opt-out. Global Privacy Control always disables analytics and marketing.') }), _jsx(Field, { label: t('Policy version'), field: "policyVersion", problems: problems, children: aria => _jsx(PolicyVersionInput, { value: value.policyVersion, onChange: onChange, maxLength: limits.policyVersion ?? 128, aria: aria }) }), _jsx("p", { className: "jini-hint", children: t('Change the policy version to ask visitors to choose again.') })] });
67
+ return _jsxs("fieldset", { disabled: disabled, className: "jini-consent-settings-fields", children: [_jsx("legend", { children: t('Rules') }), _jsxs("button", { type: "button", className: value.enabled ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.enabled, "aria-labelledby": `${id}-enabled`, "aria-describedby": `${id}-hint`, onClick: handlers.toggle, children: [_jsxs("span", { className: "jini-toggle-row-text", children: [_jsx("span", { id: `${id}-enabled`, className: "jini-toggle-row-label", children: t('Enable cookie consent on this site') }), _jsx("span", { id: `${id}-hint`, className: "jini-toggle-row-hint", children: t('Applies to published site pages.') })] }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), value.enabled && _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsxs("button", { type: "button", className: value.allowUnsafeEval === true ? 'jini-toggle-row on' : 'jini-toggle-row', role: "switch", "aria-checked": value.allowUnsafeEval === true, "aria-labelledby": `${id}-eval`, "aria-describedby": `${id}-eval-hint`, onClick: handlers.toggleUnsafeEval, children: [_jsx("span", { className: "jini-toggle-row-text", children: _jsx("span", { id: `${id}-eval`, className: "jini-toggle-row-label", children: t('Allow dynamic code (unsafe-eval)') }) }), _jsx("span", { className: "jini-toggle-row-switch", "aria-hidden": "true" })] }), _jsx(InfoTip, { triggerLabel: t('About dynamic code'), descriptionId: `${id}-eval-hint`, label: t("Some themes need this. It weakens the site's script protection. Optional scripts still wait for consent.") })] }), _jsx(Field, { label: t('Region mode'), field: "regionMode", problems: problems, children: aria => _jsx("select", { className: "jini-input", value: value.regionMode, onChange: handlers.region, ...aria, children: modes.map(mode => _jsx("option", { value: mode, children: t(REGION_LABELS[mode]) }, mode)) }) }), _jsx("p", { className: "jini-hint", children: t('California permits optional categories until opt-out. Global Privacy Control always disables analytics and marketing.') }), _jsx(Field, { label: t('Policy version'), field: "policyVersion", problems: problems, children: aria => _jsx(PolicyVersionInput, { value: value.policyVersion, onChange: onChange, maxLength: limits.policyVersion ?? 128, aria: aria }) }), _jsx("p", { className: "jini-hint", children: t('Change the policy version to ask visitors to choose again.') })] });
56
68
  }
57
69
  /** Placement, bounded appearance tokens and allowlisted custom CSS. The engine checks contrast and
58
70
  * CSS safety; its problems arrive on the `appearance` field and show under the custom CSS. */
@@ -14,6 +14,8 @@ export interface ConsentSettingsView {
14
14
  enabled: boolean;
15
15
  /** Optional for older hosts; omitted means the dynamic code exception is off. */
16
16
  allowUnsafeEval?: boolean;
17
+ /** Optional for older hosts; omitted uses the Jini banner when consent is enabled. */
18
+ bannerMode?: 'jini' | 'headless' | 'off';
17
19
  regionMode: 'auto' | 'opt-in' | 'opt-out';
18
20
  policyVersion: string;
19
21
  privacyPolicyUrl: string;
@@ -4,6 +4,9 @@ import type { ConsentAppearanceView, ConsentFieldProblem, ConsentSettingsPanelPr
4
4
  export declare function useConsentSettingsPanel({ value, onChange }: Pick<ConsentSettingsPanelProps, 'value' | 'onChange'>, _options?: {}): {
5
5
  toggle: () => void;
6
6
  toggleUnsafeEval: () => void;
7
+ bannerMode: ({ mode }: {
8
+ mode: NonNullable<ConsentSettingsView["bannerMode"]>;
9
+ }, _optional?: {}) => () => void;
7
10
  region: (event: ChangeEvent<HTMLSelectElement>) => void;
8
11
  placement: (event: ChangeEvent<HTMLSelectElement>) => void;
9
12
  text: (key: keyof ConsentSettingsView) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
@@ -4,6 +4,7 @@ export function useConsentSettingsPanel({ value, onChange }, _options = {}) {
4
4
  return {
5
5
  toggle: () => onChange({ patch: { enabled: !value.enabled } }),
6
6
  toggleUnsafeEval: () => onChange({ patch: { allowUnsafeEval: value.allowUnsafeEval !== true } }),
7
+ bannerMode: ({ mode }, _optional = {}) => () => onChange({ patch: { bannerMode: mode } }),
7
8
  region: (event) => onChange({ patch: { regionMode: event.target.value } }),
8
9
  placement: (event) => onChange({ patch: { placement: event.target.value } }),
9
10
  text: (key) => (event) => onChange({ patch: { [key]: event.target.value } }),
@@ -9,6 +9,10 @@ export declare const formsModule: {
9
9
  readonly formsNavigation: import("../core/module/token.js").AdminPortToken<import("./ports.js").FormsNavigationPort, "admin.forms.navigation">;
10
10
  };
11
11
  readonly messages: {
12
+ readonly "Basic spam protection is on.": "Basic spam protection is on.";
13
+ readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
14
+ readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
15
+ readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
12
16
  readonly "Created / Updated": "Created / Updated";
13
17
  readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
14
18
  readonly "Created {date}": "Created {date}";
@@ -1,4 +1,4 @@
1
- import { FIELD_TYPE_REGISTRY } from "@jini-ai/cms/forms";
1
+ import { FIELD_TYPE_REGISTRY, escapeFormHtml } from "@jini-ai/cms/forms";
2
2
  /** Persisted descriptors, including those derived from HTML, supply column headers, choice labels and field types. */
3
3
  export function formAnswerColumns({ fields }, display = {}) {
4
4
  return fields.map((field) => {
@@ -85,9 +85,6 @@ export function formSubmissionRows({ data, answerColumns }, { unknownFieldLabel
85
85
  return { key, label: column?.header.trim() || unknownFieldLabel, value: formatted, file: formAnswerFile({ value }) };
86
86
  });
87
87
  }
88
- function escapeHtml(value) {
89
- return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
90
- }
91
88
  const kindOf = (type) => Object.hasOwn(FIELD_TYPE_REGISTRY, type) ? FIELD_TYPE_REGISTRY[type] : { control: "input", htmlType: "text" };
92
89
  const isDefault = (field, value) => Array.isArray(field.default) ? field.default.includes(value) : field.default === value;
93
90
  /** Name, required and the stored constraints, as authored HTML attributes the CMS reads back. */
@@ -97,13 +94,13 @@ function starterAttributes(field, kind) {
97
94
  ["pattern", field.pattern], ["min", field.min], ["max", field.max], ["step", field.step], ["accept", field.accept?.join(",")],
98
95
  ...Object.entries(kind.attributes ?? {}),
99
96
  ];
100
- return `name="${escapeHtml(field.id)}"${field.required ? " required" : ""}` +
101
- constraints.filter(([, value]) => value != null && value !== "").map(([name, value]) => ` ${name}="${escapeHtml(String(value))}"`).join("");
97
+ return `name="${escapeFormHtml({ value: field.id })}"${field.required ? " required" : ""}` +
98
+ constraints.filter(([, value]) => value != null && value !== "").map(([name, value]) => ` ${name}="${escapeFormHtml({ value: String(value) })}"`).join("");
102
99
  }
103
100
  /** One field's starter markup; every control comes from the registry kind, as the public render does. */
104
101
  function starterField(field) {
105
102
  const kind = kindOf(field.type);
106
- return starterRendererFor({ kind })({ field, kind, label: escapeHtml(field.label), attrs: starterAttributes(field, kind) });
103
+ return starterRendererFor({ kind })({ field, kind, label: escapeFormHtml({ value: field.label }), attrs: starterAttributes(field, kind) });
107
104
  }
108
105
  function starterRendererFor({ kind }) {
109
106
  if (kind.control === "group")
@@ -113,7 +110,7 @@ function starterRendererFor({ kind }) {
113
110
  if (kind.control === "textarea")
114
111
  return ({ label, attrs }) => `<label>${label}\n <textarea ${attrs}></textarea>\n</label>`;
115
112
  if (kind.htmlType === "hidden")
116
- return ({ field, label, attrs }) => `<input type="hidden" ${attrs} value="${escapeHtml(String(field.default ?? ""))}" aria-label="${label}">`;
113
+ return ({ field, label, attrs }) => `<input type="hidden" ${attrs} value="${escapeFormHtml({ value: String(field.default ?? "") })}" aria-label="${label}">`;
117
114
  if (kind.htmlType === "checkbox")
118
115
  return starterCheckbox;
119
116
  return ({ label, attrs }) => `<label>${label}\n <input type="${kind.htmlType}" ${attrs}>\n</label>`;
@@ -123,16 +120,16 @@ function starterGroup({ field, kind, label, attrs }) {
123
120
  // Native `required` on every checkbox would demand ALL of them, so a checkbox group leaves it
124
121
  // off (the server still enforces one-or-more); a radio group's `required` means "pick one".
125
122
  const boxAttrs = kind.multiple ? starterAttributes({ ...field, required: false }, kind) : attrs;
126
- const boxes = (field.options ?? []).map((option) => ` <label><input type="${kind.htmlType}" ${boxAttrs} value="${escapeHtml(option.value)}"${isDefault(field, option.value) ? " checked" : ""}> ${escapeHtml(option.label)}</label>`);
123
+ const boxes = (field.options ?? []).map((option) => ` <label><input type="${kind.htmlType}" ${boxAttrs} value="${escapeFormHtml({ value: option.value })}"${isDefault(field, option.value) ? " checked" : ""}> ${escapeFormHtml({ value: option.label })}</label>`);
127
124
  return `<fieldset>\n <legend>${label}</legend>\n${boxes.join("\n")}\n</fieldset>`;
128
125
  }
129
126
  function starterSelect({ field, kind, label, attrs }) {
130
- const empty = kind.multiple ? [] : [` <option value="">${escapeHtml(field.placeholder ?? "Choose an option")}</option>`];
131
- const choices = (field.options ?? []).map((option) => ` <option value="${escapeHtml(option.value)}"${isDefault(field, option.value) ? " selected" : ""}>${escapeHtml(option.label)}</option>`);
127
+ const empty = kind.multiple ? [] : [` <option value="">${escapeFormHtml({ value: field.placeholder ?? "Choose an option" })}</option>`];
128
+ const choices = (field.options ?? []).map((option) => ` <option value="${escapeFormHtml({ value: option.value })}"${isDefault(field, option.value) ? " selected" : ""}>${escapeFormHtml({ value: option.label })}</option>`);
132
129
  return `<label>${label}\n <select ${attrs}${kind.multiple ? " multiple" : ""}>\n${[...empty, ...choices].join("\n")}\n </select>\n</label>`;
133
130
  }
134
131
  function starterCheckbox({ field, label, attrs }) {
135
- const value = field.checkboxValue !== undefined ? ` value="${escapeHtml(field.checkboxValue)}"` : "";
132
+ const value = field.checkboxValue !== undefined ? ` value="${escapeFormHtml({ value: field.checkboxValue })}"` : "";
136
133
  const checked = field.default === true || (field.checkboxValue !== undefined && field.default === field.checkboxValue) ? " checked" : "";
137
134
  return `<label><input type="checkbox"${field.type === "toggle" ? ' role="switch"' : ""} ${attrs}${value}${checked}> ${label}</label>`;
138
135
  }
@@ -144,7 +141,7 @@ function starterCheckbox({ field, label, attrs }) {
144
141
  * @complexity O(f + o) for fields and their options; no I/O.
145
142
  */
146
143
  export function formHtmlStarter({ fields }, { submitLabel = "Send" } = {}) {
147
- return fields.map(starterField).join("\n\n") + `\n\n<button type="submit">${escapeHtml(submitLabel)}</button>`;
144
+ return fields.map(starterField).join("\n\n") + `\n\n<button type="submit">${escapeFormHtml({ value: submitLabel })}</button>`;
148
145
  }
149
146
  export function formHtmlEmbed({ slug }, _optional = {}) {
150
147
  const config = JSON.stringify({ type: "form", id: slug, mode: "html" }).replace(/'/g, "&#39;");
@@ -1,5 +1,9 @@
1
1
  import type { FormsTranslator } from "./models.js";
2
2
  export declare const formsMessagesEn: {
3
+ readonly "Basic spam protection is on.": "Basic spam protection is on.";
4
+ readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
5
+ readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
6
+ readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
3
7
  readonly "Created / Updated": "Created / Updated";
4
8
  readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
5
9
  readonly "Created {date}": "Created {date}";
@@ -1,4 +1,8 @@
1
1
  export const formsMessagesEn = {
2
+ "Basic spam protection is on.": "Basic spam protection is on.",
3
+ "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.",
4
+ "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.",
5
+ "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.",
2
6
  "Created / Updated": "Created / Updated",
3
7
  "Created {created} · Updated {updated}": "Created {created} · Updated {updated}",
4
8
  "Created {date}": "Created {date}",
@@ -8,6 +8,6 @@ export function FormEditorFieldsBody(props) {
8
8
  return (_jsxs(_Fragment, { children: [_jsx("div", { className: "field-group", children: _jsxs("div", { className: "field-row", children: [_jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: "form-name", children: t("Name") }), _jsx("input", { id: "form-name", value: name, onChange: handlers.onName, ...agentHandle({ handle: "form-editor-name" }, { role: "field", label: "This form's display name" }) })] }), _jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: "form-slug", children: t("Slug") }), _jsx("input", { id: "form-slug", value: slug, disabled: !isNew, onChange: handlers.onSlug, ...agentHandle({ handle: "form-editor-slug" }, {
9
9
  role: "field",
10
10
  label: "This form's URL slug — only editable while creating a new form",
11
- }) })] })] }) }), _jsx("div", { className: "field-group", children: _jsx(FormEditorModeSwitch, { props: props, handlers: handlers }) }), _jsx(FormEditorActions, { props: props })] }));
11
+ }) })] })] }) }), _jsx("div", { className: "field-group", children: _jsx(FormEditorModeSwitch, { props: props, handlers: handlers }) }), _jsxs("div", { className: "field-group", children: [_jsx("p", { children: _jsx("strong", { children: t("Basic spam protection is on.") }) }), _jsx("p", { className: "muted", children: t("Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.") }), _jsx("p", { className: "muted", children: t("Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.") }), _jsx("p", { className: "muted", children: t("Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.") })] }), _jsx(FormEditorActions, { props: props })] }));
12
12
  }
13
13
  //# sourceMappingURL=FormEditorFieldsBody.js.map
@@ -25,6 +25,10 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
25
25
  readonly formsNavigation: import("../../core/module/token.js").AdminPortToken<import("../ports.js").FormsNavigationPort, "admin.forms.navigation">;
26
26
  };
27
27
  messages: {
28
+ readonly "Basic spam protection is on.": "Basic spam protection is on.";
29
+ readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
30
+ readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
31
+ readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
28
32
  readonly "Created / Updated": "Created / Updated";
29
33
  readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
30
34
  readonly "Created {date}": "Created {date}";
@@ -243,6 +247,10 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
243
247
  readonly formsNavigation: import("../../core/module/token.js").AdminPortToken<import("../ports.js").FormsNavigationPort, "admin.forms.navigation">;
244
248
  };
245
249
  readonly messages: {
250
+ readonly "Basic spam protection is on.": "Basic spam protection is on.";
251
+ readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
252
+ readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
253
+ readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
246
254
  readonly "Created / Updated": "Created / Updated";
247
255
  readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
248
256
  readonly "Created {date}": "Created {date}";
@@ -465,6 +473,10 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
465
473
  readonly formsNavigation: import("../../core/module/token.js").AdminPortToken<import("../ports.js").FormsNavigationPort, "admin.forms.navigation">;
466
474
  };
467
475
  readonly messages: {
476
+ readonly "Basic spam protection is on.": "Basic spam protection is on.";
477
+ readonly "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.": "Hidden-field checks, submission timing when available, duplicate suppression, and rate limits.";
478
+ readonly "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.": "Live server defaults: wait 1 second; up to 5 submissions per minute per IP address and form.";
479
+ readonly "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.": "Exported forms use their destination's protection; timing and duplicate suppression may be unavailable. Custom embeds need the generated hidden controls for hidden-field checks.";
468
480
  readonly "Created / Updated": "Created / Updated";
469
481
  readonly "Created {created} \u00B7 Updated {updated}": "Created {created} · Updated {updated}";
470
482
  readonly "Created {date}": "Created {date}";
@@ -1,13 +1,6 @@
1
- /** Browser-only live metadata hint, separate from server validation across the browser/Node
2
- * boundary. The server must enforce the same policy on writes and rendering independently.
3
- * Hints never gate Save: an invalid attribute once silently blocked saving unrelated title/alt/
4
- * caption/credit/class changes. Rejections must instead reach the authoritative atomic write.
5
- * One flat allowlist includes global and image/video extras: per-element name filtering is
6
- * unnecessary because an unused loading attribute on a wrapper is harmless. Extend the list,
7
- * not the tokenizer, when adding an allowed name. data-/aria- families remain open-ended. */
8
1
  export declare const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES: readonly ["class", "id", "style", "title", "role", "tabindex", "lang", "dir", "hidden", "translate", "draggable", "loading", "decoding", "playsinline", "muted", "loop", "autoplay", "poster"];
9
2
  /** Whether `name` is on the allowlist — a well-formed attribute name
10
- * ({@link MEDIA_HTML_ATTRIBUTE_NAME_PATTERN}) that is either an exact match against
3
+ * (`/^[a-z][a-z0-9-]*$/`) that is either an exact match against
11
4
  * {@link MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a `data-`/`aria-` prefix (both open-ended
12
5
  * families with no fixed suffix list). Case-insensitive: HTML attribute names are themselves
13
6
  * case-insensitive, and an operator typing `DATA-FOO` should not slip past a lowercase-only check.
@@ -5,44 +5,10 @@
5
5
  * One flat allowlist includes global and image/video extras: per-element name filtering is
6
6
  * unnecessary because an unused loading attribute on a wrapper is harmless. Extend the list,
7
7
  * not the tokenizer, when adding an allowed name. data-/aria- families remain open-ended. */
8
- export const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES = [
9
- // Global attributes (2026-09-23).
10
- "class",
11
- "id",
12
- "style",
13
- "title",
14
- "role",
15
- "tabindex",
16
- "lang",
17
- "dir",
18
- "hidden",
19
- "translate",
20
- "draggable",
21
- // Pre-existing <img>/<video> extras, kept everywhere (one list, not one per element).
22
- "loading",
23
- "decoding",
24
- "playsinline",
25
- "muted",
26
- "loop",
27
- "autoplay",
28
- "poster",
29
- ];
30
- /**
31
- * The shape EVERY accepted attribute name must have, checked before allowlist membership
32
- * (2026-09-07 stored-XSS fix; keep identical in `@jini-ai/cms/media`'s `html-attributes.ts` — see
33
- * that file's copy of this comment for the full incident).
34
- *
35
- * The `data-`/`aria-` families below are open-ended by design — no fixed suffix list — so the
36
- * prefix check alone accepted whatever characters {@link HTML_ATTRIBUTE_TOKEN}'s name class
37
- * (`[^\s="']+`, which excludes only whitespace, `=` and quotes) let through. `<`, `>` and `/` are
38
- * all legal in that class, and the server renderer templating a name into ` name="value"` escapes
39
- * only the VALUE — so a stored `data-x><svg/onload=alert(1)` closed the `<img>` and opened a live
40
- * `<svg onload>` on the public page. Constraining the NAME to the characters a real HTML attribute
41
- * name can contain is what makes "the value is escaped" sufficient.
42
- */
43
- const MEDIA_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
8
+ import { EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedEmbedHtmlAttributeName, parseEmbedHtmlAttributes, } from "@jini-ai/cms/widgets/markers";
9
+ export const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES = EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES;
44
10
  /** Whether `name` is on the allowlist — a well-formed attribute name
45
- * ({@link MEDIA_HTML_ATTRIBUTE_NAME_PATTERN}) that is either an exact match against
11
+ * (`/^[a-z][a-z0-9-]*$/`) that is either an exact match against
46
12
  * {@link MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a `data-`/`aria-` prefix (both open-ended
47
13
  * families with no fixed suffix list). Case-insensitive: HTML attribute names are themselves
48
14
  * case-insensitive, and an operator typing `DATA-FOO` should not slip past a lowercase-only check.
@@ -51,82 +17,7 @@ const MEDIA_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
51
17
  * one fixed-length array lookup.
52
18
  */
53
19
  export function isAllowedMediaHtmlAttributeName({ name }, _optional = {}) {
54
- const lower = name.toLowerCase();
55
- if (!MEDIA_HTML_ATTRIBUTE_NAME_PATTERN.test(lower))
56
- return false;
57
- if (lower.startsWith("data-") || lower.startsWith("aria-"))
58
- return true;
59
- return MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES.includes(lower);
60
- }
61
- /** Matches one `name`, or one `name="value"`/`name='value'`/`name=value` pair — the same loose
62
- * shape real HTML attribute syntax allows, since that is the syntax an operator typing this field
63
- * would naturally reach for. */
64
- const HTML_ATTRIBUTE_TOKEN = /([^\s="']+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g;
65
- /** The highest ASCII code point {@link isUnsafeMediaUrlValue} strips: every C0 control character
66
- * plus the ordinary space, i.e. code points `0`-`32` inclusive. Named rather than written as an
67
- * inline regex control-character range so the linter never has to reason about a literal control
68
- * character inside a pattern. Ported from the shared server parser, `html-attributes.ts`. */
69
- const ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT = 0x20;
70
- /**
71
- * Whether `value`'s scheme, once every ASCII whitespace/control character (code points `0`-`32`) is
72
- * stripped and the result lowercased, starts with `javascript:`, `vbscript:` or `data:` — the three
73
- * schemes a browser will still execute or navigate to even when the URL carries interior whitespace
74
- * a naive end-trim (the old `.trim()`-only check) would miss, e.g. `java\tscript:x`: browsers strip
75
- * tabs and newlines from inside a URL before parsing its scheme, so this check must too. Ported
76
- * 2026-09-23 from the shared server parser's `isUnsafeUrlValue` (`html-attributes.ts`).
77
- *
78
- * @complexity O(n) in the value's length (one filter pass, one prefix test).
79
- */
80
- function isUnsafeMediaUrlValue(value) {
81
- let stripped = "";
82
- for (const char of value) {
83
- // `char` is never empty, so `charCodeAt(0)` is always a number; for an astral character it is
84
- // the high surrogate (>= 0xD800), which is kept exactly as the full code point would be.
85
- if (char.charCodeAt(0) > ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT)
86
- stripped += char;
87
- }
88
- stripped = stripped.toLowerCase();
89
- return stripped.startsWith("javascript:") || stripped.startsWith("vbscript:") || stripped.startsWith("data:");
90
- }
91
- /**
92
- * Whether a `style` value carries a known CSS injection or CSS-escape-bypass vector:
93
- * `expression(...)` (legacy IE script-in-CSS), a `javascript:`/`vbscript:` value inside a CSS
94
- * `url(...)`, `-moz-binding`/`behavior:` (XBL/HTC script bindings), `@import` (pulls in a second,
95
- * unvetted stylesheet), or ANY backslash — a CSS escape sequence (`\65` is `e`) can respell any of
96
- * the above past a plain substring check, so a backslash anywhere in the value is rejected outright.
97
- * Plain `url(...)` values (background images) are otherwise allowed. Ported 2026-09-23 from the
98
- * shared server parser's `isUnsafeStyleValue` (`html-attributes.ts`).
99
- *
100
- * @complexity O(n) in the value's length (one regex test).
101
- */
102
- const UNSAFE_MEDIA_STYLE_PATTERN = /expression\(|javascript:|vbscript:|-moz-binding|behavior:|@import|\\/i;
103
- function isUnsafeMediaStyleValue(value) {
104
- return UNSAFE_MEDIA_STYLE_PATTERN.test(value);
105
- }
106
- /** Classifies one already-tokenized `name`/`value` pair. Split out of {@link parseMediaHtmlAttributes}
107
- * so each rejection reason is its own directly testable branch, and so the loop below reads as
108
- * "tokenize, then classify" rather than one function doing both.
109
- *
110
- * Order matters (ported 2026-09-23 from the shared server parser's `classifyEmbedHtmlAttributeToken`):
111
- * `on*` is checked FIRST (so an `onerror` always reports as `event-handler`, the more actionable
112
- * reason, never the generic `disallowed-name`), then the URL-scheme check runs against EVERY value
113
- * regardless of name (matching the pre-existing `javascript:` check this is ported from), then
114
- * allowlist membership, then — only for an already-allowed `style` — the CSS-specific unsafe-value
115
- * check.
116
- *
117
- * @complexity O(1) — four fixed checks against one already-extracted token; each check's own O(n) in
118
- * the token's length is linear, not nested.
119
- */
120
- function classifyMediaHtmlAttributeToken(name, value) {
121
- if (name.startsWith("on"))
122
- return { reason: "event-handler", attribute: name };
123
- if (isUnsafeMediaUrlValue(value))
124
- return { reason: "unsafe-url", attribute: name };
125
- if (!isAllowedMediaHtmlAttributeName({ name }))
126
- return { reason: "disallowed-name", attribute: name };
127
- if (name === "style" && isUnsafeMediaStyleValue(value))
128
- return { reason: "unsafe-style", attribute: name };
129
- return null;
20
+ return isAllowedEmbedHtmlAttributeName({ name });
130
21
  }
131
22
  /**
132
23
  * Parses the `HTML attributes` field's free text (`name="value" name2="value2"`, or a bare
@@ -154,54 +45,8 @@ function classifyMediaHtmlAttributeToken(name, value) {
154
45
  * attributes plus O(e) for e rejected tokens.
155
46
  */
156
47
  export function parseMediaHtmlAttributes({ text }, _optional = {}) {
157
- const trimmed = text.trim();
158
- if (trimmed === "")
159
- return { attributes: {}, errors: [], error: null };
160
- const attributes = {};
161
- const errors = [];
162
- HTML_ATTRIBUTE_TOKEN.lastIndex = 0;
163
- let consumed = 0;
164
- // A `for` loop (rather than `while ((match = …exec(trimmed)) !== null)`) keeps every assignment
165
- // to `match` in statement position, never inside the loop's own test expression.
166
- for (let match = HTML_ATTRIBUTE_TOKEN.exec(trimmed); match !== null; match = HTML_ATTRIBUTE_TOKEN.exec(trimmed)) {
167
- // Non-whitespace text between the previous match and this one is a fragment the token pattern
168
- // could not parse as a name (e.g. a stray quote) — the tokenizer has lost sync, so everything
169
- // parsed so far is discarded rather than trusted.
170
- const skipped = trimmed.slice(consumed, match.index).trim();
171
- if (skipped !== "")
172
- return malformedMediaHtmlAttributes({ errors, fragment: skipped });
173
- consumed = match.index + match[0].length;
174
- const { name, value } = readMediaHtmlAttributeToken({ match });
175
- const rejection = classifyMediaHtmlAttributeToken(name, value);
176
- if (rejection) {
177
- errors.push(rejection);
178
- continue;
179
- }
180
- attributes[name] = value;
181
- }
182
- const trailing = trimmed.slice(consumed).trim();
183
- if (trailing !== "")
184
- return malformedMediaHtmlAttributes({ errors, fragment: trailing });
185
- return { attributes, errors, error: errors[0] ?? null };
186
- }
187
- /** The lowercased name and the value of one {@link HTML_ATTRIBUTE_TOKEN} match: whichever of the
188
- * double-quoted, single-quoted or unquoted value groups matched, or `""` for a bare boolean name.
189
- *
190
- * @complexity O(n) in the name's length (one lowercase pass).
191
- */
192
- function readMediaHtmlAttributeToken({ match }, _optional = {}) {
193
- return { name: match[1].toLowerCase(), value: match[2] ?? match[3] ?? match[4] ?? "" };
194
- }
195
- /** The fail-closed result once the tokenizer loses sync: `fragment` (already trimmed) is appended
196
- * to `errors` as the final `malformed` entry, and every accepted attribute is discarded — see
197
- * {@link parseMediaHtmlAttributes}'s header for why. Mutates `errors` (the caller's own list).
198
- *
199
- * @complexity O(1).
200
- */
201
- function malformedMediaHtmlAttributes({ errors, fragment }, _optional = {}) {
202
- const malformed = { reason: "malformed", attribute: fragment };
203
- errors.push(malformed);
204
- return { attributes: {}, errors, error: malformed };
48
+ // Media hints always use inline-attribute policy; stylesheet escape allowances do not apply.
49
+ return parseEmbedHtmlAttributes({ text });
205
50
  }
206
51
  /** The hint names the rejected token so operators can distinguish a typo from a hard no.
207
52
  * This is client feedback only; the server must independently enforce write/render policy. */
@@ -1,4 +1,4 @@
1
- import type { FormEvent, ComponentType } from 'react';
1
+ import type { FormEvent } from 'react';
2
2
  import type { TabBarTab } from '@jini-ai/ui/tab-strip';
3
3
  import type { SeoTranslator, SeoSettingsPatch, SeoSettings, SeoEntryAnalysis } from '../../models.js';
4
4
  export { buildSeoSettingsPatch, resolveSeoTabId } from '../../rules.js';
@@ -47,24 +47,4 @@ export interface SeoTabController {
47
47
  regenerateSitemap: () => void;
48
48
  openSitemapModal: () => void;
49
49
  }
50
- /** Panel selection is presentation dispatch; this never changes the defaults form's field set. */
51
- export declare function seoTabPanel({ activeTabId, controller, components }: {
52
- activeTabId: import('../../rules.js').SeoTabId;
53
- controller: SeoTabController;
54
- components: {
55
- SeoDefaultsTab: ComponentType<{
56
- controller: SeoTabController;
57
- }>;
58
- SeoSitemapTab: ComponentType<{
59
- controller: SeoTabController;
60
- }>;
61
- SeoEntrySection: ComponentType<{
62
- locale: string;
63
- }>;
64
- };
65
- }, _optional?: Record<string, never>): import("react").ReactElement<{
66
- controller: SeoTabController;
67
- }, string | import("react").JSXElementConstructor<any>> | import("react").ReactElement<{
68
- locale: string;
69
- }, string | import("react").JSXElementConstructor<any>>;
70
50
  //# sourceMappingURL=SeoPage.hooks.d.ts.map
@@ -26,12 +26,4 @@ export function useAnalyzePanel({ analysis }, _optional = {}) {
26
26
  export function useSeoDefaults({ save }, _optional = {}) {
27
27
  return { submit: (event) => { event.preventDefault(); void save(buildSeoSettingsPatch({ form: new FormData(event.currentTarget) })); } };
28
28
  }
29
- /** Panel selection is presentation dispatch; this never changes the defaults form's field set. */
30
- export function seoTabPanel({ activeTabId, controller, components }, _optional = {}) {
31
- if (activeTabId === 'sitemap')
32
- return createElement(components.SeoSitemapTab, { controller });
33
- if (activeTabId === 'entries')
34
- return createElement(components.SeoEntrySection, { locale: controller.locale });
35
- return createElement(components.SeoDefaultsTab, { controller });
36
- }
37
29
  //# sourceMappingURL=SeoPage.hooks.js.map
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useAnalyzePanel, useSeoDefaults, useSeoTabs, seoTabPanel } from "../hooks/SeoPage.hooks.js";
2
+ import { useAnalyzePanel, useSeoDefaults, useSeoTabs } from "../hooks/SeoPage.hooks.js";
3
3
  import { agentHandle } from "@jini-ai/agentic";
4
4
  import { TabBar } from "@jini-ai/ui/tab-strip";
5
5
  import { InfoTip, Select } from "@jini-ai/ui/admin-widgets";
@@ -230,7 +230,15 @@ export function Seo({ tabId, onTabChange, useSeoHook = useWiredSeo } = {}) {
230
230
  regenerateSitemap,
231
231
  openSitemapModal,
232
232
  };
233
- return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions, social previews, and robots directives." }) })] }), _jsx("div", { className: "jini-page-actions", children: headerActions })] }), error ? _jsx("div", { className: "jini-notice jini-notice-error", children: error }) : null, notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, _jsx(TabBar, { ariaLabel: "SEO", tabs: tabs, activeId: activeTabId, onChange: changeTab, containerHandle: "seo-tab-bar" }), seoTabPanel({ activeTabId, controller, components: { SeoDefaultsTab, SeoSitemapTab, SeoEntrySection } }), sitemapModalOpen ? (_jsx(SitemapModal, { locale: locale, sitemapEnabled: settings.sitemapEnabled, regenerating: saving, onRegenerate: regenerateSitemap, onClose: closeSitemapModal })) : null] }));
233
+ return (_jsxs("div", { className: "jini-page jini-seo-page", children: [_jsxs("div", { className: "jini-page-header", children: [_jsxs("div", { className: "jini-page-header-text", children: [_jsx("p", { className: "jini-page-kicker", children: t({ locale: locale, key: "Marketing" }) }), _jsx("h1", { className: "jini-page-title", children: "SEO" }), _jsx("p", { className: "jini-page-description", children: t({ locale: locale, key: "Site-wide defaults for meta titles, descriptions, social previews, and robots directives." }) })] }), _jsx("div", { className: "jini-page-actions", children: headerActions })] }), error ? _jsx("div", { className: "jini-notice jini-notice-error", children: error }) : null, notice ? _jsx("div", { className: "jini-notice", children: notice }) : null, _jsx(TabBar, { ariaLabel: "SEO", tabs: tabs, activeId: activeTabId, onChange: changeTab, containerHandle: "seo-tab-bar" }), seoTabPanel({ activeTabId, controller }), sitemapModalOpen ? (_jsx(SitemapModal, { locale: locale, sitemapEnabled: settings.sitemapEnabled, regenerating: saving, onRegenerate: regenerateSitemap, onClose: closeSitemapModal })) : null] }));
234
+ }
235
+ /** Panel selection is presentation dispatch; this never changes the defaults form's field set. */
236
+ function seoTabPanel({ activeTabId, controller }, _optional = {}) {
237
+ if (activeTabId === 'sitemap')
238
+ return _jsx(SeoSitemapTab, { controller: controller });
239
+ if (activeTabId === 'entries')
240
+ return _jsx(SeoEntrySection, { locale: controller.locale });
241
+ return _jsx(SeoDefaultsTab, { controller: controller });
234
242
  }
235
243
  export { Seo as SeoPage };
236
244
  export default Seo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/admin",
3
- "version": "0.6.12",
3
+ "version": "0.6.15-rc.2",
4
4
  "description": "Composable admin surface for Jini-hosted products: framework-free contracts, panel registry, and route model in `/core`; React shell and panels in `/react`. Import only the layers you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -448,12 +448,12 @@
448
448
  "peerDependencies": {
449
449
  "react": "^18.3.0 || ^19.0.0",
450
450
  "react-dom": "^18.3.0 || ^19.0.0",
451
- "@jini-ai/agentic": "^0.6.10",
452
- "@jini-ai/ui": "^0.6.10",
453
- "@jini-ai/ui-kit": "^0.6.10",
454
- "@jini-ai/agent-plugins": "^0.6.10",
455
- "@jini-ai/cms": "^0.6.10",
456
- "@jini-ai/core": "^0.6.10"
451
+ "@jini-ai/agentic": "^0.6.15-rc.2",
452
+ "@jini-ai/ui": "^0.6.15-rc.2",
453
+ "@jini-ai/ui-kit": "^0.6.15-rc.2",
454
+ "@jini-ai/agent-plugins": "^0.6.15-rc.2",
455
+ "@jini-ai/cms": "^0.6.15-rc.2",
456
+ "@jini-ai/core": "^0.6.15-rc.2"
457
457
  },
458
458
  "peerDependenciesMeta": {
459
459
  "react": {
@@ -491,12 +491,12 @@
491
491
  "react": "^19.2.0",
492
492
  "react-dom": "^19.2.0",
493
493
  "@testing-library/user-event": "^14.6.1",
494
- "@jini-ai/ui": "^0.6.12",
495
- "@jini-ai/agentic": "^0.6.12",
496
- "@jini-ai/ui-kit": "^0.6.12",
497
- "@jini-ai/agent-plugins": "^0.6.12",
498
- "@jini-ai/cms": "^0.6.12",
499
- "@jini-ai/core": "^0.6.12"
494
+ "@jini-ai/agentic": "^0.6.15-rc.2",
495
+ "@jini-ai/core": "^0.6.15-rc.2",
496
+ "@jini-ai/agent-plugins": "^0.6.15-rc.2",
497
+ "@jini-ai/cms": "^0.6.15-rc.2",
498
+ "@jini-ai/ui": "^0.6.15-rc.2",
499
+ "@jini-ai/ui-kit": "^0.6.15-rc.2"
500
500
  },
501
501
  "typesVersions": {
502
502
  "*": {