@k2b/cloud 0.37.0 → 0.39.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.
- package/package.json +8 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +11 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +460 -66
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +46 -3
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/help.ts +23 -4
- package/src/api/index.ts +2 -1
- package/src/api/mcp.ts +8 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +17 -2
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/help.ts +136 -10
- package/src/server/services/access.ts +5 -1
- package/src/server/services/geo.ts +21 -4
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +22 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +67 -43
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +62 -11
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +72 -25
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +34 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +11 -0
- package/src/ssr/shell-shortcuts.ts +19 -0
- package/src/styles/effects.css +689 -185
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/resource-search.css +25 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { attachResizeSeparator, parkWorkspaceDetailDrawers } from "@k2b/ui";
|
|
2
|
+
import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, readPanelCookie, serializePanelCookie } from "../_internal/panel-cookie";
|
|
3
|
+
import { platformMessages } from "../ssr/platform-messages";
|
|
4
|
+
import { readShellData } from "../ssr/shell-data";
|
|
5
|
+
import type { PanelRequest } from "./panel";
|
|
6
|
+
import { isPanelCloseMessage, isPanelReadyMessage } from "./panel-messages";
|
|
7
|
+
|
|
8
|
+
export const PANEL_READY_TIMEOUT_MS = 10_000;
|
|
9
|
+
let installed: { dispose: () => void; request: (request: PanelRequest) => void } | undefined;
|
|
10
|
+
|
|
11
|
+
export function handlePanelRequest(request: PanelRequest): void {
|
|
12
|
+
installed?.request(request);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Owns the slot's single iframe, independently persisted desktop and transient phone state. */
|
|
16
|
+
export function installPanelSlot(): () => void {
|
|
17
|
+
if (installed) return installed.dispose;
|
|
18
|
+
const canvas = document.querySelector<HTMLElement>(".cloud-app-canvas");
|
|
19
|
+
const slot = document.getElementById("cloud-panel");
|
|
20
|
+
const body = slot?.querySelector<HTMLElement>(".cloud-panel__body");
|
|
21
|
+
const panel = readShellData()?.panel;
|
|
22
|
+
if (!canvas || !slot || !body || !panel?.providers.length) return () => {};
|
|
23
|
+
const t = platformMessages.resolve([document.documentElement.lang || "en"]).t;
|
|
24
|
+
// The mode uses the same px breakpoint as panel.css and the shell, read fresh, so CSS and the controller always
|
|
25
|
+
// agree; `phoneChange` only tells when to look again.
|
|
26
|
+
const isPhone = () => !window.matchMedia("(min-width: 768px)").matches;
|
|
27
|
+
const phoneChange = window.matchMedia("(max-width: 767.98px)");
|
|
28
|
+
const column = window.matchMedia("(min-width: 1024px)");
|
|
29
|
+
const attribute = () => (isPhone() ? "data-panel-sheet" : "data-panel");
|
|
30
|
+
const isOpen = () => canvas.getAttribute(attribute()) === "open";
|
|
31
|
+
const status = slot.querySelector<HTMLElement>("[data-cloud-panel-status]");
|
|
32
|
+
const separator = slot.querySelector<HTMLElement>(".cloud-panel__resize");
|
|
33
|
+
const tabs = [...slot.querySelectorAll<HTMLButtonElement>("[data-cloud-panel-provider]")];
|
|
34
|
+
const grow = slot.querySelector<HTMLButtonElement>("[data-cloud-panel-grow]");
|
|
35
|
+
let provider = panel.providers.find((item) => item.id === slot.dataset.provider) ?? panel.providers[0]!;
|
|
36
|
+
let frame = slot.querySelector<HTMLIFrameElement>("[data-cloud-panel-frame]");
|
|
37
|
+
let search: string | undefined;
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
39
|
+
let opener: HTMLElement | undefined;
|
|
40
|
+
const rem = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
41
|
+
const storedWidth = parseFloat(canvas.style.getPropertyValue("--cloud-panel-width-set"));
|
|
42
|
+
let committedWidth = Number.isFinite(storedWidth) ? storedWidth : undefined;
|
|
43
|
+
let width = committedWidth ?? PANEL_MIN_WIDTH_REM;
|
|
44
|
+
const setWidth = (value: number) => {
|
|
45
|
+
width = value;
|
|
46
|
+
canvas.style.setProperty("--cloud-panel-width-set", `${value}rem`);
|
|
47
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(value * 100) / 100));
|
|
48
|
+
};
|
|
49
|
+
const seedWidth = () => {
|
|
50
|
+
if (committedWidth !== undefined || !column.matches || !isOpen()) return;
|
|
51
|
+
const measured = slot.getBoundingClientRect().width / rem();
|
|
52
|
+
if (measured > 0) width = Math.min(PANEL_MAX_WIDTH_REM, Math.max(PANEL_MIN_WIDTH_REM, measured));
|
|
53
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(width * 100) / 100));
|
|
54
|
+
};
|
|
55
|
+
// Another tab may have saved a newer width; opening or closing keeps it instead of writing back an old one.
|
|
56
|
+
const adoptStoredWidth = (apply: boolean) => {
|
|
57
|
+
const stored = readPanelCookie(
|
|
58
|
+
document.cookie,
|
|
59
|
+
panel.providers.map((item) => item.id),
|
|
60
|
+
).widthRem;
|
|
61
|
+
if (stored === undefined) return;
|
|
62
|
+
committedWidth = stored;
|
|
63
|
+
if (apply && stored !== width) setWidth(stored);
|
|
64
|
+
};
|
|
65
|
+
const writeCookie = () => {
|
|
66
|
+
if (isPhone() || canvas.hasAttribute("data-panel-transient")) return;
|
|
67
|
+
document.cookie = serializePanelCookie(
|
|
68
|
+
{ open: canvas.dataset.panel === "open", provider: provider.id, widthRem: committedWidth },
|
|
69
|
+
location.protocol === "https:",
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
const update = () => {
|
|
73
|
+
for (const button of document.querySelectorAll("[data-cloud-panel-toggle]")) button.setAttribute("aria-expanded", String(isOpen()));
|
|
74
|
+
for (const tab of tabs) {
|
|
75
|
+
const selected = tab.dataset.cloudPanelProvider === provider.id;
|
|
76
|
+
tab.setAttribute("aria-selected", String(selected));
|
|
77
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
78
|
+
}
|
|
79
|
+
const description = slot.querySelector<HTMLElement>(".k2b-placeholder__description");
|
|
80
|
+
if (description) description.textContent = t.panelNotResponding({ label: provider.label });
|
|
81
|
+
};
|
|
82
|
+
const clearTimer = () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
timer = undefined;
|
|
85
|
+
};
|
|
86
|
+
const loading = () => {
|
|
87
|
+
clearTimer();
|
|
88
|
+
slot.dataset.state = "loading";
|
|
89
|
+
if (status) status.textContent = t.panelLoading({ label: provider.label });
|
|
90
|
+
timer = setTimeout(() => {
|
|
91
|
+
slot.dataset.state = "failed";
|
|
92
|
+
if (status) status.textContent = `${t.panelLoadFailed}. ${t.panelNotResponding({ label: provider.label })}`;
|
|
93
|
+
}, PANEL_READY_TIMEOUT_MS);
|
|
94
|
+
};
|
|
95
|
+
const markReady = () => {
|
|
96
|
+
clearTimer();
|
|
97
|
+
slot.dataset.state = "ready";
|
|
98
|
+
if (status) status.textContent = "";
|
|
99
|
+
};
|
|
100
|
+
const unload = () => {
|
|
101
|
+
clearTimer();
|
|
102
|
+
frame?.remove();
|
|
103
|
+
frame = null;
|
|
104
|
+
slot.dataset.state = "closed";
|
|
105
|
+
if (status) status.textContent = "";
|
|
106
|
+
};
|
|
107
|
+
const target = () => {
|
|
108
|
+
const url = new URL(provider.href, location.origin);
|
|
109
|
+
if (search !== undefined) url.search = search;
|
|
110
|
+
return url;
|
|
111
|
+
};
|
|
112
|
+
// The page the iframe shows now; a reader may have moved on inside the panel since it loaded.
|
|
113
|
+
const shownHref = () => {
|
|
114
|
+
try {
|
|
115
|
+
return frame?.contentWindow?.location.href;
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const load = (replace = false) => {
|
|
121
|
+
const url = target();
|
|
122
|
+
if (!replace && frame?.src === url.href) return;
|
|
123
|
+
frame?.remove();
|
|
124
|
+
// The server validates every provider path; the iframe still never leaves this origin.
|
|
125
|
+
if (url.origin !== location.origin) {
|
|
126
|
+
frame = null;
|
|
127
|
+
clearTimer();
|
|
128
|
+
slot.dataset.state = "failed";
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
frame = document.createElement("iframe");
|
|
132
|
+
frame.className = "cloud-panel__frame";
|
|
133
|
+
frame.title = provider.label;
|
|
134
|
+
frame.dataset.cloudPanelFrame = provider.id;
|
|
135
|
+
frame.src = url.href;
|
|
136
|
+
loading();
|
|
137
|
+
body.append(frame);
|
|
138
|
+
};
|
|
139
|
+
let removeFallbackFocus = () => {};
|
|
140
|
+
const returnFocus = () => {
|
|
141
|
+
if (opener?.isConnected && opener.getClientRects().length && !opener.closest("[inert]")) {
|
|
142
|
+
opener.focus();
|
|
143
|
+
if (document.activeElement === opener) return;
|
|
144
|
+
}
|
|
145
|
+
const main = canvas.querySelector<HTMLElement>("[data-cloud-app-area] main.layout-content-main, main[data-cloud-app-area]");
|
|
146
|
+
if (!main) return;
|
|
147
|
+
removeFallbackFocus();
|
|
148
|
+
if (!main.hasAttribute("tabindex")) {
|
|
149
|
+
main.tabIndex = -1;
|
|
150
|
+
removeFallbackFocus = () => {
|
|
151
|
+
main.removeAttribute("tabindex");
|
|
152
|
+
main.removeEventListener("blur", removeFallbackFocus);
|
|
153
|
+
};
|
|
154
|
+
main.addEventListener("blur", removeFallbackFocus, { once: true });
|
|
155
|
+
}
|
|
156
|
+
main.focus();
|
|
157
|
+
};
|
|
158
|
+
const close = () => {
|
|
159
|
+
if (!isOpen()) return;
|
|
160
|
+
canvas.removeAttribute(attribute());
|
|
161
|
+
disposeResize();
|
|
162
|
+
disposeResize = attachResize();
|
|
163
|
+
unload();
|
|
164
|
+
adoptStoredWidth(false);
|
|
165
|
+
writeCookie();
|
|
166
|
+
update();
|
|
167
|
+
returnFocus();
|
|
168
|
+
};
|
|
169
|
+
const open = (id: string, nextSearch?: string) => {
|
|
170
|
+
const next = panel.providers.find((item) => item.id === id);
|
|
171
|
+
if (!next) return;
|
|
172
|
+
parkWorkspaceDetailDrawers(document);
|
|
173
|
+
const active = document.activeElement;
|
|
174
|
+
if (active instanceof HTMLElement && active !== document.body && !slot.contains(active)) opener = active;
|
|
175
|
+
provider = next;
|
|
176
|
+
search = typeof nextSearch === "string" && nextSearch.length <= 2048 ? nextSearch : undefined;
|
|
177
|
+
canvas.setAttribute(attribute(), "open");
|
|
178
|
+
slot.dataset.provider = provider.id;
|
|
179
|
+
adoptStoredWidth(true);
|
|
180
|
+
seedWidth();
|
|
181
|
+
// A requested search is a destination: it replaces a page reached inside the panel.
|
|
182
|
+
const shown = shownHref();
|
|
183
|
+
load(search !== undefined && shown !== undefined && shown !== "about:blank" && shown !== target().href);
|
|
184
|
+
writeCookie();
|
|
185
|
+
update();
|
|
186
|
+
frame?.focus();
|
|
187
|
+
};
|
|
188
|
+
const request = (request: PanelRequest) => {
|
|
189
|
+
if (request.action === "open") open(request.provider, request.search);
|
|
190
|
+
else if (request.action === "close" || isOpen()) close();
|
|
191
|
+
else open(provider.id, search);
|
|
192
|
+
};
|
|
193
|
+
const message = (event: MessageEvent<unknown>) => {
|
|
194
|
+
if (!frame || event.source !== frame.contentWindow || event.origin !== location.origin) return;
|
|
195
|
+
if (isPanelReadyMessage(event.data, provider.id)) markReady();
|
|
196
|
+
else if (isPanelCloseMessage(event.data)) close();
|
|
197
|
+
};
|
|
198
|
+
const click = (event: MouseEvent) => {
|
|
199
|
+
if (!(event.target instanceof Element)) return;
|
|
200
|
+
if (event.target.closest("[data-cloud-panel-close]")) close();
|
|
201
|
+
else if (event.target.closest("[data-cloud-panel-retry]") && isOpen()) {
|
|
202
|
+
load(true);
|
|
203
|
+
frame?.focus();
|
|
204
|
+
} else if (event.target.closest("[data-cloud-panel-grow]") && isPhone()) {
|
|
205
|
+
const full = slot.dataset.panelSize !== "full";
|
|
206
|
+
if (full) slot.dataset.panelSize = "full";
|
|
207
|
+
else delete slot.dataset.panelSize;
|
|
208
|
+
grow?.setAttribute("aria-label", full ? t.shrinkPanel : t.enlargePanel);
|
|
209
|
+
} else {
|
|
210
|
+
const tab = event.target.closest<HTMLElement>("[data-cloud-panel-provider]");
|
|
211
|
+
if (tab?.dataset.cloudPanelProvider) open(tab.dataset.cloudPanelProvider);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const key = (event: KeyboardEvent) => {
|
|
215
|
+
if (event.defaultPrevented || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
216
|
+
if (!(event.target instanceof Element) || !slot.contains(event.target)) return;
|
|
217
|
+
if (event.key === "Escape") {
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
close();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const index = tabs.findIndex((tab) => tab === event.target);
|
|
223
|
+
if (index < 0) return;
|
|
224
|
+
let next: number;
|
|
225
|
+
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
|
|
226
|
+
else if (event.key === "ArrowLeft") next = (index + tabs.length - 1) % tabs.length;
|
|
227
|
+
else if (event.key === "Home") next = 0;
|
|
228
|
+
else if (event.key === "End") next = tabs.length - 1;
|
|
229
|
+
else return; // Buttons activate with native Enter/Space clicks.
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
for (const [index, tab] of tabs.entries()) tab.tabIndex = index === next ? 0 : -1;
|
|
232
|
+
tabs[next]?.focus();
|
|
233
|
+
};
|
|
234
|
+
const modeChange = () => {
|
|
235
|
+
disposeResize();
|
|
236
|
+
if (isOpen()) {
|
|
237
|
+
seedWidth();
|
|
238
|
+
load();
|
|
239
|
+
} else unload();
|
|
240
|
+
disposeResize = attachResize();
|
|
241
|
+
update();
|
|
242
|
+
};
|
|
243
|
+
const closeSheet = () => {
|
|
244
|
+
const wasOpen = canvas.dataset.panelSheet === "open";
|
|
245
|
+
canvas.removeAttribute("data-panel-sheet");
|
|
246
|
+
if (wasOpen && isPhone()) {
|
|
247
|
+
unload();
|
|
248
|
+
update();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const pageShow = (event: PageTransitionEvent) => {
|
|
252
|
+
if (event.persisted) closeSheet();
|
|
253
|
+
};
|
|
254
|
+
const entryChange = (event: NavigationCurrentEntryChangeEvent) => {
|
|
255
|
+
if (event.navigationType === "push") closeSheet();
|
|
256
|
+
};
|
|
257
|
+
let dragWidth = width;
|
|
258
|
+
let dragStoredWidth = committedWidth;
|
|
259
|
+
let dragInlineWidth = "";
|
|
260
|
+
const restoreDragWidth = () => {
|
|
261
|
+
width = dragWidth;
|
|
262
|
+
committedWidth = dragStoredWidth;
|
|
263
|
+
if (dragInlineWidth) canvas.style.setProperty("--cloud-panel-width-set", dragInlineWidth);
|
|
264
|
+
else canvas.style.removeProperty("--cloud-panel-width-set");
|
|
265
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(width * 100) / 100));
|
|
266
|
+
};
|
|
267
|
+
let endingDrag = false;
|
|
268
|
+
seedWidth();
|
|
269
|
+
const attachResize = () =>
|
|
270
|
+
separator && column.matches
|
|
271
|
+
? attachResizeSeparator(separator, {
|
|
272
|
+
min: PANEL_MIN_WIDTH_REM,
|
|
273
|
+
max: PANEL_MAX_WIDTH_REM,
|
|
274
|
+
value: () => width,
|
|
275
|
+
get unit() {
|
|
276
|
+
return rem();
|
|
277
|
+
},
|
|
278
|
+
step: 1,
|
|
279
|
+
largeStep: 4,
|
|
280
|
+
grow: "start",
|
|
281
|
+
collapseBelow: 16,
|
|
282
|
+
onResize: (value) => {
|
|
283
|
+
if (endingDrag) {
|
|
284
|
+
endingDrag = false;
|
|
285
|
+
restoreDragWidth();
|
|
286
|
+
} else setWidth(value);
|
|
287
|
+
},
|
|
288
|
+
onResizeEnd: (value) => {
|
|
289
|
+
endingDrag = false;
|
|
290
|
+
committedWidth = value;
|
|
291
|
+
writeCookie();
|
|
292
|
+
},
|
|
293
|
+
onCollapseIntent: (armed) => {
|
|
294
|
+
if (armed) slot.dataset.closing = "true";
|
|
295
|
+
else delete slot.dataset.closing;
|
|
296
|
+
},
|
|
297
|
+
onCollapse: () => {
|
|
298
|
+
endingDrag = false;
|
|
299
|
+
restoreDragWidth();
|
|
300
|
+
close();
|
|
301
|
+
},
|
|
302
|
+
onDragChange: (dragging) => {
|
|
303
|
+
if (dragging) {
|
|
304
|
+
dragWidth = width;
|
|
305
|
+
dragStoredWidth = committedWidth;
|
|
306
|
+
dragInlineWidth = canvas.style.getPropertyValue("--cloud-panel-width-set");
|
|
307
|
+
slot.dataset.resizing = "true";
|
|
308
|
+
} else {
|
|
309
|
+
delete slot.dataset.resizing;
|
|
310
|
+
endingDrag = true;
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
: () => {};
|
|
315
|
+
let disposeResize = attachResize();
|
|
316
|
+
window.addEventListener("message", message);
|
|
317
|
+
window.addEventListener("keydown", key);
|
|
318
|
+
slot.addEventListener("click", click);
|
|
319
|
+
phoneChange.addEventListener("change", modeChange);
|
|
320
|
+
column.addEventListener("change", modeChange);
|
|
321
|
+
window.addEventListener("pageshow", pageShow);
|
|
322
|
+
window.addEventListener("popstate", closeSheet);
|
|
323
|
+
window.navigation?.addEventListener("currententrychange", entryChange);
|
|
324
|
+
window.addEventListener("resize", seedWidth);
|
|
325
|
+
// An SSR iframe can have posted its one-shot ready before this lazy module
|
|
326
|
+
// arrived. Its runtime also marks its own same-origin document, so read that
|
|
327
|
+
// once the listener is in place instead of loading the provider again.
|
|
328
|
+
if (isOpen() && frame) {
|
|
329
|
+
if (frame.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id) markReady();
|
|
330
|
+
else loading();
|
|
331
|
+
} else if (isOpen()) load();
|
|
332
|
+
else unload();
|
|
333
|
+
update();
|
|
334
|
+
let disposed = false;
|
|
335
|
+
const dispose = () => {
|
|
336
|
+
if (disposed) return;
|
|
337
|
+
disposed = true;
|
|
338
|
+
clearTimer();
|
|
339
|
+
disposeResize();
|
|
340
|
+
removeFallbackFocus();
|
|
341
|
+
window.removeEventListener("message", message);
|
|
342
|
+
window.removeEventListener("keydown", key);
|
|
343
|
+
slot.removeEventListener("click", click);
|
|
344
|
+
phoneChange.removeEventListener("change", modeChange);
|
|
345
|
+
column.removeEventListener("change", modeChange);
|
|
346
|
+
window.removeEventListener("pageshow", pageShow);
|
|
347
|
+
window.removeEventListener("popstate", closeSheet);
|
|
348
|
+
window.navigation?.removeEventListener("currententrychange", entryChange);
|
|
349
|
+
window.removeEventListener("resize", seedWidth);
|
|
350
|
+
installed = undefined;
|
|
351
|
+
};
|
|
352
|
+
installed = { dispose, request };
|
|
353
|
+
return dispose;
|
|
354
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type PanelRequest = { action: "open"; provider: string; search?: string; accepted: boolean } | { action: "close" | "toggle" };
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface WindowEventMap {
|
|
5
|
+
"cloud.panel.request": CustomEvent<PanelRequest>;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Opens a visible provider. False leaves navigation with the caller when this page has no matching slot. */
|
|
10
|
+
export function openPanel(provider: string, options?: { search?: string }): boolean {
|
|
11
|
+
const detail: PanelRequest = {
|
|
12
|
+
action: "open",
|
|
13
|
+
provider,
|
|
14
|
+
search: typeof options?.search === "string" && options.search.length <= 2048 ? options.search : undefined,
|
|
15
|
+
accepted: false,
|
|
16
|
+
};
|
|
17
|
+
window.dispatchEvent(new CustomEvent("cloud.panel.request", { detail }));
|
|
18
|
+
return detail.accepted;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Closes the panel when it is open. */
|
|
22
|
+
export function closePanel(): void {
|
|
23
|
+
if (window.parent !== window && document.querySelector("[data-cloud-panel-page]")) {
|
|
24
|
+
window.parent.postMessage({ type: "cloud.panel.close" }, location.origin);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
window.dispatchEvent(new CustomEvent<PanelRequest>("cloud.panel.request", { detail: { action: "close" } }));
|
|
28
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { timed } from "@k2b/stdlib/solid";
|
|
2
2
|
import { announce, Button, IconButton, Placeholder, TextInput } from "@k2b/ui";
|
|
3
3
|
import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
|
|
4
|
-
import
|
|
4
|
+
import { CloudResourceRefSchema } from "../contracts/capabilities";
|
|
5
|
+
import { FILE_PROVIDER_CONTEXT_MAX_REFS, type FileProviderContext, type FileProviderEntry } from "../contracts/file-provider";
|
|
5
6
|
import type { fileChooserMessages } from "./file-chooser-messages";
|
|
6
7
|
import { type FileProviderCaller, FileProviderError, type FileProviderSource, listProviderFolder } from "./file-providers";
|
|
7
8
|
|
|
@@ -18,7 +19,12 @@ const EMPTY_PAGES_FOLLOWED = 5;
|
|
|
18
19
|
* and its load state. `settled` runs after a folder has loaded or the source list is back, so the dialog can move
|
|
19
20
|
* focus to its first row.
|
|
20
21
|
*/
|
|
21
|
-
export const createProviderFolder = (props: {
|
|
22
|
+
export const createProviderFolder = (props: {
|
|
23
|
+
caller: () => FileProviderCaller;
|
|
24
|
+
t: () => Messages;
|
|
25
|
+
settled: () => void;
|
|
26
|
+
context?: FileProviderContext | ((signal: AbortSignal) => Promise<FileProviderContext>);
|
|
27
|
+
}) => {
|
|
22
28
|
const [location, setLocation] = createSignal<FolderLocation>(null);
|
|
23
29
|
const [filter, setFilter] = createSignal("");
|
|
24
30
|
const [query, setQuery] = createSignal("");
|
|
@@ -27,6 +33,26 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
|
|
|
27
33
|
const [writable, setWritable] = createSignal(false);
|
|
28
34
|
const [status, setStatus] = createSignal<"loading" | "ready" | "more" | "error">("ready");
|
|
29
35
|
const [error, setError] = createSignal<FileProviderError | null>(null);
|
|
36
|
+
const [contextual, setContextual] = createSignal(false);
|
|
37
|
+
let resolvedContext: FileProviderContext | undefined;
|
|
38
|
+
const contextFor = async (signal: AbortSignal): Promise<FileProviderContext> => {
|
|
39
|
+
if (resolvedContext) return resolvedContext;
|
|
40
|
+
const raw = typeof props.context === "function" ? await props.context(signal) : props.context;
|
|
41
|
+
signal.throwIfAborted();
|
|
42
|
+
const refs: FileProviderContext["refs"] = [];
|
|
43
|
+
const seen = new Set<string>();
|
|
44
|
+
for (const value of raw?.refs ?? []) {
|
|
45
|
+
const parsed = CloudResourceRefSchema.safeParse(value);
|
|
46
|
+
if (!parsed.success) continue;
|
|
47
|
+
const key = JSON.stringify([parsed.data.type, parsed.data.id]);
|
|
48
|
+
if (seen.has(key)) continue;
|
|
49
|
+
seen.add(key);
|
|
50
|
+
refs.push(parsed.data);
|
|
51
|
+
if (refs.length === FILE_PROVIDER_CONTEXT_MAX_REFS) break;
|
|
52
|
+
}
|
|
53
|
+
resolvedContext = { refs };
|
|
54
|
+
return resolvedContext;
|
|
55
|
+
};
|
|
30
56
|
let request: AbortController | undefined;
|
|
31
57
|
onCleanup(() => request?.abort());
|
|
32
58
|
|
|
@@ -48,12 +74,15 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
|
|
|
48
74
|
setStatus(more ? "more" : "loading");
|
|
49
75
|
setError(null);
|
|
50
76
|
try {
|
|
77
|
+
const context = current.provider.context === true && props.context !== undefined ? await contextFor(pending.signal) : undefined;
|
|
78
|
+
if (pending.signal.aborted) return;
|
|
79
|
+
setContextual(context !== undefined);
|
|
51
80
|
let cursor = more ? (next() ?? undefined) : undefined;
|
|
52
81
|
let followed = 0;
|
|
53
82
|
for (;;) {
|
|
54
83
|
const page = await listProviderFolder(
|
|
55
84
|
current.provider,
|
|
56
|
-
{ parent: current.trail.at(-1)?.id, query: query() || undefined, cursor },
|
|
85
|
+
{ parent: current.trail.at(-1)?.id, query: query() || undefined, cursor, context },
|
|
57
86
|
props.caller(),
|
|
58
87
|
pending.signal,
|
|
59
88
|
);
|
|
@@ -103,6 +132,7 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
|
|
|
103
132
|
setFilter("");
|
|
104
133
|
setQuery("");
|
|
105
134
|
setLocation(target);
|
|
135
|
+
setContextual(false);
|
|
106
136
|
setItems([]);
|
|
107
137
|
setNext(null);
|
|
108
138
|
setWritable(false);
|
|
@@ -138,6 +168,7 @@ export const createProviderFolder = (props: { caller: () => FileProviderCaller;
|
|
|
138
168
|
|
|
139
169
|
return {
|
|
140
170
|
location,
|
|
171
|
+
contextual,
|
|
141
172
|
filter,
|
|
142
173
|
query,
|
|
143
174
|
items,
|
|
@@ -236,7 +267,12 @@ export function FolderToolbar(props: { folder: ProviderFolder; t: () => Messages
|
|
|
236
267
|
* One folder in the dialog body: loading, the error with Try again or Up, the empty folder, no matches, or `children`
|
|
237
268
|
* with Load more below while the folder continues.
|
|
238
269
|
*/
|
|
239
|
-
export function FolderContents(props: {
|
|
270
|
+
export function FolderContents(props: {
|
|
271
|
+
folder: ProviderFolder;
|
|
272
|
+
t: () => Messages;
|
|
273
|
+
noMatchingFiles?: string;
|
|
274
|
+
children: JSX.Element;
|
|
275
|
+
}): JSX.Element {
|
|
240
276
|
const folder = props.folder;
|
|
241
277
|
return (
|
|
242
278
|
<Switch>
|
|
@@ -268,7 +304,24 @@ export function FolderContents(props: { folder: ProviderFolder; t: () => Message
|
|
|
268
304
|
<Match when={folder.items().length === 0 && !folder.next()}>
|
|
269
305
|
<Show
|
|
270
306
|
when={folder.query()}
|
|
271
|
-
fallback={
|
|
307
|
+
fallback={
|
|
308
|
+
<Show
|
|
309
|
+
when={folder.contextual() && folder.location()?.trail.length === 1}
|
|
310
|
+
fallback={<Placeholder variant="panel" class="cloud-file-chooser__state" icon="ti ti-folder" title={props.t().emptyFolder} />}
|
|
311
|
+
>
|
|
312
|
+
<Placeholder
|
|
313
|
+
variant="panel"
|
|
314
|
+
class="cloud-file-chooser__state"
|
|
315
|
+
icon="ti ti-file-search"
|
|
316
|
+
title={props.noMatchingFiles ?? props.t().noMatchingFiles}
|
|
317
|
+
action={
|
|
318
|
+
<Button size="sm" variant="secondary" onClick={() => folder.navigate(null)}>
|
|
319
|
+
{props.t().otherSource}
|
|
320
|
+
</Button>
|
|
321
|
+
}
|
|
322
|
+
/>
|
|
323
|
+
</Show>
|
|
324
|
+
}
|
|
272
325
|
>
|
|
273
326
|
<Placeholder
|
|
274
327
|
variant="panel"
|
|
@@ -6,10 +6,14 @@ import { resourceSearchDialogOptions } from "./resource-search-dialog";
|
|
|
6
6
|
import { resourceSearchMessages } from "./resource-search-messages";
|
|
7
7
|
|
|
8
8
|
export type CloudResourcePickerItem = SearchItem;
|
|
9
|
+
/** Narrows the search on the server to one app and, optionally, one of its search tags; shown as the removable chip. */
|
|
10
|
+
export type CloudResourcePickerScope = { appId: string; tag?: string; label: string; icon?: string };
|
|
9
11
|
export type CloudResourcePickerOptions = {
|
|
10
12
|
title?: string;
|
|
11
13
|
placeholder?: string;
|
|
12
14
|
initialAppId?: string;
|
|
15
|
+
scope?: CloudResourcePickerScope;
|
|
16
|
+
query?: string;
|
|
13
17
|
excludeRefs?: readonly CloudResourceRef[];
|
|
14
18
|
requireReader?: boolean;
|
|
15
19
|
resourceType?: string;
|
|
@@ -20,8 +24,19 @@ export const openCloudResourcePicker = (options: CloudResourcePickerOptions = {}
|
|
|
20
24
|
const t = resourceSearchMessages.resolve([locale]).t;
|
|
21
25
|
return dialogCore.open<CloudResourcePickerItem>(
|
|
22
26
|
(close) => (
|
|
23
|
-
<CloudResourceSearch
|
|
27
|
+
<CloudResourceSearch
|
|
28
|
+
{...options}
|
|
29
|
+
request={options.scope !== undefined || options.query !== undefined ? { scope: options.scope, query: options.query } : undefined}
|
|
30
|
+
title={options.title ?? t.chooseResource}
|
|
31
|
+
selectionMode
|
|
32
|
+
onSelect={close}
|
|
33
|
+
onClose={() => close()}
|
|
34
|
+
/>
|
|
24
35
|
),
|
|
25
|
-
|
|
36
|
+
// A phone shows the picker as a full-screen sheet; global search keeps its floating card.
|
|
37
|
+
{
|
|
38
|
+
...resourceSearchDialogOptions(options.title ?? t.chooseResource),
|
|
39
|
+
panelClassName: "cloud-search-dialog cloud-search-dialog--picker",
|
|
40
|
+
},
|
|
26
41
|
);
|
|
27
42
|
};
|
|
@@ -55,6 +55,7 @@ export type WorkflowRunDetailRow = WorkflowRunRow & {
|
|
|
55
55
|
effectsUsed: Record<string, number>;
|
|
56
56
|
effectBudget: Record<string, number>;
|
|
57
57
|
eventData: Record<string, unknown> | null;
|
|
58
|
+
eventPayloadExpired: boolean;
|
|
58
59
|
steps: WorkflowStepRow[];
|
|
59
60
|
children: Record<RunState, number>;
|
|
60
61
|
};
|
package/src/config/env.ts
CHANGED
|
@@ -91,6 +91,20 @@ const registry = defineEnv({
|
|
|
91
91
|
doc: "Core only: temporary first-administrator or recovery login token; remove it after normal administrator sign-in works.",
|
|
92
92
|
},
|
|
93
93
|
|
|
94
|
+
// Outbound network
|
|
95
|
+
OUTGOING_MAIL_ALLOWED_TARGETS: {
|
|
96
|
+
schema: envList,
|
|
97
|
+
default: [],
|
|
98
|
+
scope: "runtime",
|
|
99
|
+
doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for outgoing mail SMTP and bounce IMAP connections; unset allows only public targets, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
|
|
100
|
+
},
|
|
101
|
+
GEO_ALLOWED_TARGETS: {
|
|
102
|
+
schema: envList,
|
|
103
|
+
default: ["geo"],
|
|
104
|
+
scope: "runtime",
|
|
105
|
+
doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for Geo requests; unset allows public targets and `geo`, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
|
|
106
|
+
},
|
|
107
|
+
|
|
94
108
|
// Identity and OAuth
|
|
95
109
|
CLOUD_IDENTITY_KEY_ENCRYPTION_KEY: {
|
|
96
110
|
schema: z.string(),
|
|
@@ -30,7 +30,10 @@ export const APP_ACTION_LIMITS = {
|
|
|
30
30
|
valueChars: 300,
|
|
31
31
|
} as const;
|
|
32
32
|
/** Commands a Cloud serves beyond the first version, announced in `/info` as `features`. */
|
|
33
|
-
export const APP_APPROVAL_FEATURES = ["actions"] as const;
|
|
33
|
+
export const APP_APPROVAL_FEATURES = ["actions", "terminal-logins"] as const;
|
|
34
|
+
|
|
35
|
+
export const APP_APPROVAL_ACCEPT = ["terminal-logins"] as const;
|
|
36
|
+
export type AppApprovalAccept = (typeof APP_APPROVAL_ACCEPT)[number];
|
|
34
37
|
|
|
35
38
|
const Id = z.string().uuid();
|
|
36
39
|
const Secret = z.string().regex(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/);
|
|
@@ -77,7 +80,17 @@ export const AppDeviceCommandSchema = z.discriminatedUnion("operation", [
|
|
|
77
80
|
/** Stores the authenticator's opaque push token for sign-in wake-ups on this device. */
|
|
78
81
|
z.object({ operation: z.literal("push"), token: z.string().regex(/^[A-Za-z0-9_-]{43}$/) }).strict(),
|
|
79
82
|
/** Pending sign-ins and the newest open Action approvals of the signing device's account in one call. */
|
|
80
|
-
z
|
|
83
|
+
z
|
|
84
|
+
.object({
|
|
85
|
+
operation: z.literal("inbox"),
|
|
86
|
+
accept: z
|
|
87
|
+
.array(z.enum(APP_APPROVAL_ACCEPT))
|
|
88
|
+
.min(1)
|
|
89
|
+
.max(8)
|
|
90
|
+
.refine((values) => new Set(values).size === values.length)
|
|
91
|
+
.optional(),
|
|
92
|
+
})
|
|
93
|
+
.strict(),
|
|
81
94
|
/** One open Action approval as the device shows it, with the digest the device recomputes before it decides. */
|
|
82
95
|
z.object({ operation: z.literal("action"), actionId: Id }).strict(),
|
|
83
96
|
/** Decides an Action approval. `digest` binds the decision to exactly the view the person saw. */
|
|
@@ -121,6 +134,7 @@ export const appDeviceProofMessage = (proof: AppDeviceProof): string => {
|
|
|
121
134
|
c.operation,
|
|
122
135
|
...(c.operation === "decide" ? [c.requestId, c.challenge, c.comparison, c.decision] : []),
|
|
123
136
|
...(c.operation === "push" ? [c.token] : []),
|
|
137
|
+
...(c.operation === "inbox" && c.accept !== undefined ? [c.accept] : []),
|
|
124
138
|
...(c.operation === "action" ? [c.actionId] : []),
|
|
125
139
|
...(c.operation === "act" ? [c.actionId, c.digest, c.decision] : []),
|
|
126
140
|
]);
|
|
@@ -151,8 +165,40 @@ export const AppDeviceViewSchema = z
|
|
|
151
165
|
assisted: z.boolean(),
|
|
152
166
|
})
|
|
153
167
|
.strict();
|
|
168
|
+
/** Plain text on one line: no control, format or bidirectional characters, so an app cannot fake Cloud's own frame. */
|
|
169
|
+
const PlainText = (max: number) =>
|
|
170
|
+
z
|
|
171
|
+
.string()
|
|
172
|
+
.refine((value) => Array.from(value).length <= max, `Must contain at most ${max} code points`)
|
|
173
|
+
.regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u);
|
|
174
|
+
export const AppTerminalLoginSchema = z
|
|
175
|
+
.object({
|
|
176
|
+
client: z
|
|
177
|
+
.object({
|
|
178
|
+
// Bound encoded bytes including escapes, so five complete consent rows fit the response budget.
|
|
179
|
+
id: PlainText(256)
|
|
180
|
+
.min(1)
|
|
181
|
+
.refine((value) => new TextEncoder().encode(JSON.stringify(value)).length <= 258),
|
|
182
|
+
name: PlainText(APP_ACTION_LIMITS.nameChars).min(1),
|
|
183
|
+
})
|
|
184
|
+
.strict(),
|
|
185
|
+
scopes: z
|
|
186
|
+
.array(z.string().regex(/^[a-z][a-z0-9_:.-]{0,39}$/))
|
|
187
|
+
.min(1)
|
|
188
|
+
.max(16),
|
|
189
|
+
lifetimeSeconds: z.number().int().positive(),
|
|
190
|
+
})
|
|
191
|
+
.strict();
|
|
192
|
+
export type AppTerminalLogin = z.infer<typeof AppTerminalLoginSchema>;
|
|
154
193
|
export const AppPendingLoginSchema = z
|
|
155
|
-
.object({
|
|
194
|
+
.object({
|
|
195
|
+
requestId: Id,
|
|
196
|
+
challenge: Secret,
|
|
197
|
+
comparison: Comparison,
|
|
198
|
+
createdAt: DateTime,
|
|
199
|
+
expiresAt: DateTime,
|
|
200
|
+
terminal: AppTerminalLoginSchema.optional(),
|
|
201
|
+
})
|
|
156
202
|
.strict();
|
|
157
203
|
export const AppPairingPayloadSchema = z
|
|
158
204
|
.object({ protocol: z.literal(APP_APPROVAL_PROTOCOL), issuer: z.string().url(), pairingId: Id, secret: Secret, expiresAt: DateTime })
|
|
@@ -215,12 +261,6 @@ export const AppDeviceAccountSchema = z
|
|
|
215
261
|
})
|
|
216
262
|
.strict();
|
|
217
263
|
export type AppDeviceAccount = z.infer<typeof AppDeviceAccountSchema>;
|
|
218
|
-
/** Plain text on one line: no control, format or bidirectional characters, so an app cannot fake Cloud's own frame. */
|
|
219
|
-
const PlainText = (max: number) =>
|
|
220
|
-
z
|
|
221
|
-
.string()
|
|
222
|
-
.refine((value) => Array.from(value).length <= max, `Must contain at most ${max} code points`)
|
|
223
|
-
.regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u);
|
|
224
264
|
const ActionApp = z
|
|
225
265
|
.object({ id: PlainText(APP_ACTION_LIMITS.nameChars).min(1), name: PlainText(APP_ACTION_LIMITS.nameChars).min(1) })
|
|
226
266
|
.strict();
|