@k2b/cloud 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/package.json +9 -6
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
@@ -0,0 +1,301 @@
1
+ import { timed } from "@k2b/stdlib/solid";
2
+ import { announce, Button, IconButton, Placeholder, TextInput } from "@k2b/ui";
3
+ import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
4
+ import type { FileProviderEntry } from "../contracts/file-provider";
5
+ import type { fileChooserMessages } from "./file-chooser-messages";
6
+ import { type FileProviderCaller, FileProviderError, type FileProviderSource, listProviderFolder } from "./file-providers";
7
+
8
+ type Messages = ReturnType<typeof fileChooserMessages.resolve>["t"];
9
+ type Crumb = { id?: string; name: string };
10
+ /** Where the dialog is: a folder of one provider, or the source list when `null`. */
11
+ export type FolderLocation = { provider: FileProviderSource; trail: readonly Crumb[] } | null;
12
+
13
+ /** Empty pages may still continue; a dialog follows at most this many of them before it shows "Load more". */
14
+ const EMPTY_PAGES_FOLLOWED = 5;
15
+
16
+ /**
17
+ * Browsing one provider's folders, shared by choosing and saving: the location, the name filter, the folder's pages,
18
+ * and its load state. `settled` runs after a folder has loaded or the source list is back, so the dialog can move
19
+ * focus to its first row.
20
+ */
21
+ export const createProviderFolder = (props: { caller: () => FileProviderCaller; t: () => Messages; settled: () => void }) => {
22
+ const [location, setLocation] = createSignal<FolderLocation>(null);
23
+ const [filter, setFilter] = createSignal("");
24
+ const [query, setQuery] = createSignal("");
25
+ const [items, setItems] = createSignal<FileProviderEntry[]>([]);
26
+ const [next, setNext] = createSignal<string | null>(null);
27
+ const [writable, setWritable] = createSignal(false);
28
+ const [status, setStatus] = createSignal<"loading" | "ready" | "more" | "error">("ready");
29
+ const [error, setError] = createSignal<FileProviderError | null>(null);
30
+ let request: AbortController | undefined;
31
+ onCleanup(() => request?.abort());
32
+
33
+ const folderName = () => location()?.trail.at(-1)?.name ?? props.t().sources;
34
+
35
+ const load = async (more = false) => {
36
+ const current = location();
37
+ if (!current) return;
38
+ request?.abort();
39
+ const pending = new AbortController();
40
+ request = pending;
41
+ if (!more) {
42
+ batch(() => {
43
+ setItems([]);
44
+ setNext(null);
45
+ setWritable(false);
46
+ });
47
+ }
48
+ setStatus(more ? "more" : "loading");
49
+ setError(null);
50
+ try {
51
+ let cursor = more ? (next() ?? undefined) : undefined;
52
+ let followed = 0;
53
+ for (;;) {
54
+ const page = await listProviderFolder(
55
+ current.provider,
56
+ { parent: current.trail.at(-1)?.id, query: query() || undefined, cursor },
57
+ props.caller(),
58
+ pending.signal,
59
+ );
60
+ if (pending.signal.aborted) return;
61
+ batch(() => {
62
+ setItems((previous) => [...previous, ...page.items]);
63
+ setNext(page.next);
64
+ if (!more) setWritable(page.writable);
65
+ });
66
+ if (items().length > 0 || !page.next || ++followed >= EMPTY_PAGES_FOLLOWED) break;
67
+ cursor = page.next;
68
+ }
69
+ setStatus("ready");
70
+ if (!more) {
71
+ announce(props.t().folderAnnounced({ folder: folderName(), count: items().length, more: Boolean(next()) }));
72
+ props.settled();
73
+ }
74
+ } catch (cause) {
75
+ if (pending.signal.aborted) return;
76
+ setError(
77
+ cause instanceof FileProviderError ? cause : new FileProviderError("INTERNAL", cause instanceof Error ? cause.message : "", 500),
78
+ );
79
+ setStatus(more ? "ready" : "error");
80
+ }
81
+ };
82
+
83
+ const applyQuery = (value: string) => {
84
+ setQuery(value.trim());
85
+ void load();
86
+ };
87
+ const { debouncedFn: applyFilter, cancel: cancelFilter } = timed.debounce(applyQuery, 250);
88
+ onCleanup(cancelFilter);
89
+ const clearFilter = () => {
90
+ cancelFilter();
91
+ setFilter("");
92
+ if (location() && query()) applyQuery("");
93
+ };
94
+ const changeFilter = (value: string) => {
95
+ setFilter(value);
96
+ if (location()) applyFilter(value);
97
+ };
98
+
99
+ const navigate = (target: FolderLocation) => {
100
+ cancelFilter();
101
+ request?.abort();
102
+ batch(() => {
103
+ setFilter("");
104
+ setQuery("");
105
+ setLocation(target);
106
+ setItems([]);
107
+ setNext(null);
108
+ setWritable(false);
109
+ setError(null);
110
+ setStatus(target ? "loading" : "ready");
111
+ });
112
+ if (target) void load();
113
+ else props.settled();
114
+ };
115
+ const openProvider = (provider: FileProviderSource) => navigate({ provider, trail: [{ name: provider.name }] });
116
+ const openFolder = (entry: FileProviderEntry) => {
117
+ const current = location();
118
+ if (current) navigate({ provider: current.provider, trail: [...current.trail, { id: entry.id, name: entry.name }] });
119
+ };
120
+ const up = () => {
121
+ const current = location();
122
+ if (!current) return;
123
+ navigate(current.trail.length > 1 ? { provider: current.provider, trail: current.trail.slice(0, -1) } : null);
124
+ };
125
+
126
+ const errorView = createMemo(() => {
127
+ const value = error();
128
+ if (!value) return null;
129
+ const t = props.t();
130
+ const app = location()?.provider.name ?? "";
131
+ if (value.status === 0) return { title: t.offline, description: t.offlineHint, retry: true };
132
+ if (value.status === 403) return { title: t.noAccess, description: t.backHint, retry: false };
133
+ if (value.status === 404) return { title: t.notFound, description: t.backHint, retry: false };
134
+ if (value.status >= 502 || value.code === "APP_UNAVAILABLE")
135
+ return { title: t.unavailable({ app }), description: t.unavailableHint, retry: true };
136
+ return { title: t.loadFailed, description: value.message || undefined, retry: true };
137
+ });
138
+
139
+ return {
140
+ location,
141
+ filter,
142
+ query,
143
+ items,
144
+ next,
145
+ writable,
146
+ status,
147
+ error,
148
+ errorView,
149
+ folderName,
150
+ load,
151
+ navigate,
152
+ openProvider,
153
+ openFolder,
154
+ up,
155
+ changeFilter,
156
+ clearFilter,
157
+ };
158
+ };
159
+
160
+ export type ProviderFolder = ReturnType<typeof createProviderFolder>;
161
+
162
+ /**
163
+ * The path row with Up and the name filter. With `sources`, the path starts at the source list; without it, the
164
+ * provider is the top, so Up stops there.
165
+ */
166
+ export function FolderToolbar(props: { folder: ProviderFolder; t: () => Messages; disabled: boolean; sources: boolean }): JSX.Element {
167
+ const top = () => {
168
+ const current = props.folder.location();
169
+ return !current || (!props.sources && current.trail.length === 1);
170
+ };
171
+ return (
172
+ <div class="cloud-file-chooser__toolbar">
173
+ <nav class="cloud-file-chooser__crumbs" aria-label={props.t().breadcrumbs}>
174
+ <IconButton size="sm" variant="ghost" label={props.t().up} disabled={top() || props.disabled} onClick={props.folder.up}>
175
+ <i class="ti ti-arrow-up" aria-hidden="true" />
176
+ </IconButton>
177
+ <ol>
178
+ <Show when={props.sources}>
179
+ <li>
180
+ <Button
181
+ size="sm"
182
+ variant={props.folder.location() ? "text" : "subtle"}
183
+ aria-current={props.folder.location() ? undefined : "location"}
184
+ disabled={props.disabled}
185
+ onClick={() => props.folder.navigate(null)}
186
+ >
187
+ {props.t().sources}
188
+ </Button>
189
+ </li>
190
+ </Show>
191
+ <For each={props.folder.location()?.trail ?? []}>
192
+ {(crumb, index) => {
193
+ const last = () => index() === (props.folder.location()?.trail.length ?? 0) - 1;
194
+ return (
195
+ <li>
196
+ <Show when={props.sources || index() > 0}>
197
+ <span aria-hidden="true">/</span>
198
+ </Show>
199
+ <Button
200
+ size="sm"
201
+ variant={last() ? "subtle" : "text"}
202
+ aria-current={last() ? "location" : undefined}
203
+ disabled={props.disabled}
204
+ onClick={() => {
205
+ const current = props.folder.location();
206
+ if (current && !last())
207
+ props.folder.navigate({ provider: current.provider, trail: current.trail.slice(0, index() + 1) });
208
+ }}
209
+ >
210
+ {crumb.name}
211
+ </Button>
212
+ </li>
213
+ );
214
+ }}
215
+ </For>
216
+ </ol>
217
+ </nav>
218
+ <TextInput
219
+ class="cloud-file-chooser__filter"
220
+ type="search"
221
+ icon="ti ti-filter"
222
+ aria-label={props.t().filter}
223
+ placeholder={props.t().filter}
224
+ maxLength={200}
225
+ disabled={props.disabled}
226
+ value={props.folder.filter}
227
+ onValueChange={props.folder.changeFilter}
228
+ clearable
229
+ onClear={props.folder.clearFilter}
230
+ />
231
+ </div>
232
+ );
233
+ }
234
+
235
+ /**
236
+ * One folder in the dialog body: loading, the error with Try again or Up, the empty folder, no matches, or `children`
237
+ * with Load more below while the folder continues.
238
+ */
239
+ export function FolderContents(props: { folder: ProviderFolder; t: () => Messages; children: JSX.Element }): JSX.Element {
240
+ const folder = props.folder;
241
+ return (
242
+ <Switch>
243
+ <Match when={folder.status() === "loading"}>
244
+ <Placeholder state="loading" variant="panel" class="cloud-file-chooser__state" description={props.t().loading} />
245
+ </Match>
246
+ <Match when={folder.status() === "error" && folder.errorView()}>
247
+ {(view) => (
248
+ <Placeholder
249
+ state="error"
250
+ variant="panel"
251
+ class="cloud-file-chooser__state"
252
+ title={view().title}
253
+ description={view().description}
254
+ action={
255
+ view().retry ? (
256
+ <Button size="sm" variant="secondary" onClick={() => void folder.load()}>
257
+ {props.t().retry}
258
+ </Button>
259
+ ) : (
260
+ <Button size="sm" variant="secondary" onClick={folder.up}>
261
+ <i class="ti ti-arrow-up" aria-hidden="true" /> {props.t().up}
262
+ </Button>
263
+ )
264
+ }
265
+ />
266
+ )}
267
+ </Match>
268
+ <Match when={folder.items().length === 0 && !folder.next()}>
269
+ <Show
270
+ when={folder.query()}
271
+ fallback={<Placeholder variant="panel" class="cloud-file-chooser__state" icon="ti ti-folder" title={props.t().emptyFolder} />}
272
+ >
273
+ <Placeholder
274
+ variant="panel"
275
+ class="cloud-file-chooser__state"
276
+ icon="ti ti-filter-off"
277
+ title={props.t().noMatches({ query: folder.query() })}
278
+ action={
279
+ <Button size="sm" variant="secondary" onClick={folder.clearFilter}>
280
+ {props.t().clearFilter}
281
+ </Button>
282
+ }
283
+ />
284
+ </Show>
285
+ </Match>
286
+ <Match when={true}>
287
+ {props.children}
288
+ <div class="cloud-file-chooser__more">
289
+ <Show when={folder.error() && folder.status() === "ready"}>
290
+ <Placeholder state="error" variant="inline" align="left" description={folder.errorView()?.title} />
291
+ </Show>
292
+ <Show when={folder.next()}>
293
+ <Button variant="secondary" size="sm" loading={folder.status() === "more"} onClick={() => void folder.load(true)}>
294
+ {folder.error() ? props.t().retry : props.t().loadMore}
295
+ </Button>
296
+ </Show>
297
+ </div>
298
+ </Match>
299
+ </Switch>
300
+ );
301
+ }
@@ -0,0 +1,39 @@
1
+ import { createSignal } from "solid-js";
2
+ import { FileProviderError, type FileProviderSource } from "./file-providers";
3
+
4
+ /** The catalog refuses the caller, such as a visitor of a public page or a session that has just expired. */
5
+ const refused = (error: unknown) => error instanceof FileProviderError && (error.status === 401 || error.status === 403);
6
+
7
+ /**
8
+ * One page's file providers, shared by choosing and saving: the list is loaded once and kept; a failed load is tried
9
+ * again on the next request. A caller the catalog refuses has no providers for now, and the next request asks again,
10
+ * because a renewed session may see them. `known` is the list once it arrived, and reactive.
11
+ */
12
+ export const createProviderList = (loadProviders: () => Promise<FileProviderSource[]>) => {
13
+ let request: Promise<readonly FileProviderSource[]> | undefined;
14
+ const [known, setKnown] = createSignal<readonly FileProviderSource[] | undefined>(undefined);
15
+ const providers = (): Promise<readonly FileProviderSource[]> => {
16
+ request ??= loadProviders().then(
17
+ (list) => {
18
+ setKnown(list);
19
+ return list;
20
+ },
21
+ (error: unknown) => {
22
+ request = undefined;
23
+ if (!refused(error)) throw error;
24
+ setKnown([]);
25
+ return [];
26
+ },
27
+ );
28
+ return request;
29
+ };
30
+ return {
31
+ providers,
32
+ known,
33
+ /** Whether a load is running or kept; only a refusal or a failure leaves none behind. */
34
+ requested: () => request !== undefined,
35
+ prefetch: () => void providers().catch(() => undefined),
36
+ };
37
+ };
38
+
39
+ export type ProviderList = ReturnType<typeof createProviderList>;
@@ -0,0 +1,35 @@
1
+ import { render } from "solid-js/web";
2
+ import { SaveFilesButton, saveFiles } from "./files";
3
+
4
+ /**
5
+ * Buttons that call the real `saveFiles` for a browser test, which answers the catalog, the provider's Queries and
6
+ * Action, and its write streams. `#save` saves one attachment by URL, `#save-all` two, `#save-big` one above the
7
+ * provider's limit. Each call gets a fresh signal that `window.abortSaving()` aborts. Each result lands in
8
+ * `window.saved`. `SaveFilesButton` renders the shared icon button for the same attachment.
9
+ */
10
+ type Saved = Awaited<ReturnType<typeof saveFiles>>;
11
+ const results: Saved[] = [];
12
+ let saving = new AbortController();
13
+ Object.assign(window, { saved: results, abortSaving: () => saving.abort() });
14
+
15
+ const attachment = (name: string) => ({ name, content: `/source/${encodeURIComponent(name)}` });
16
+ const sets: Record<string, Parameters<typeof saveFiles>[0]> = {
17
+ save: [attachment("report.pdf")],
18
+ "save-all": [attachment("report.pdf"), attachment("notes.txt")],
19
+ "save-big": [{ ...attachment("huge.zip"), size: 5_000 }],
20
+ };
21
+ for (const [id, files] of Object.entries(sets)) {
22
+ const button = document.createElement("button");
23
+ button.type = "button";
24
+ button.id = id;
25
+ button.textContent = id;
26
+ document.body.append(button);
27
+ button.addEventListener("click", () => {
28
+ saving = new AbortController();
29
+ void saveFiles(files, { signal: saving.signal }).then((saved) => void results.push(saved));
30
+ });
31
+ }
32
+ const icon = document.createElement("div");
33
+ icon.id = "icon";
34
+ document.body.append(icon);
35
+ render(() => <SaveFilesButton files={() => sets.save!} />, icon);
@@ -0,0 +1,107 @@
1
+ import { dialogCore, IconButton, type IconButtonProps, panelDialogWorkspaceOptions, toast, useLocale } from "@k2b/ui";
2
+ import { createSignal, type JSX, splitProps } from "solid-js";
3
+ import type { FileProviderList } from "./FileChooser";
4
+ import { FileSaver, type SavedFile } from "./FileSaver";
5
+ import { fileChooserMessages } from "./file-chooser-messages";
6
+ import type { FileProviderCaller, SaveFileSource } from "./file-providers";
7
+ import type { ProviderList } from "./provider-list";
8
+
9
+ export type { SavedFile } from "./FileSaver";
10
+ export type { SaveFileSource } from "./file-providers";
11
+
12
+ export type SaveFilesOptions = {
13
+ /** Closes the dialog and stops what is still running; files saved before stay saved. */
14
+ signal?: AbortSignal;
15
+ };
16
+
17
+ /** The icon of the save action, for menus that show it beside other actions. */
18
+ export const SAVE_FILES_ICON = "ti ti-folder-down";
19
+
20
+ /**
21
+ * One page's saving into file providers, over the same provider list as choosing. `caller` carries the locale and,
22
+ * in tests, the transport.
23
+ */
24
+ export const createFileSaving = (
25
+ list: ProviderList,
26
+ caller: () => FileProviderCaller = () => ({ locale: document.documentElement.lang || "en" }),
27
+ ) => {
28
+ const saveFiles = async (files: readonly SaveFileSource[], options: SaveFilesOptions = {}): Promise<SavedFile[]> => {
29
+ if (files.length === 0 || options.signal?.aborted) return [];
30
+ // Only a refusal leaves an empty list without a request; asking again lets a renewed session find its apps.
31
+ const known = list.known()?.length === 0 && !list.requested() ? undefined : list.known();
32
+ const [providers, setProviders] = createSignal<FileProviderList>(known ? { state: "ready", providers: known } : { state: "loading" });
33
+ const load = () => {
34
+ setProviders({ state: "loading" });
35
+ list.providers().then(
36
+ (value) => setProviders({ state: "ready", providers: value }),
37
+ () => setProviders({ state: "error" }),
38
+ );
39
+ };
40
+ if (!known) load();
41
+ const saved: SavedFile[] = [];
42
+ const running = new Set<Promise<void>>();
43
+ await dialogCore.open<void>(
44
+ (close) => (
45
+ <FileSaver
46
+ files={files}
47
+ providers={providers}
48
+ retryProviders={load}
49
+ caller={caller}
50
+ saved={saved}
51
+ running={running}
52
+ close={() => close()}
53
+ />
54
+ ),
55
+ {
56
+ ...panelDialogWorkspaceOptions,
57
+ panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
58
+ // While the folder still loads, the dialog itself holds focus; its first row takes it once it arrives.
59
+ initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]') ?? dialog,
60
+ signal: options.signal,
61
+ history: true,
62
+ },
63
+ );
64
+ // Closing stops the transfers; a receipt that was already on its way still counts.
65
+ await Promise.all(running);
66
+ const first = saved[0];
67
+ if (first) {
68
+ const t = fileChooserMessages.resolve([caller().locale]).t;
69
+ toast.success(
70
+ saved.length === 1 && files.length === 1
71
+ ? t.savedOne({ name: first.name, app: first.app })
72
+ : t.savedMany({ count: saved.length, total: files.length, app: first.app }),
73
+ first.href ? { action: { label: t.showIn({ app: first.app }), href: first.href } } : undefined,
74
+ );
75
+ }
76
+ return saved;
77
+ };
78
+
79
+ /**
80
+ * The save action's label: "Save to Files" once one provider that stores files is known, otherwise "Save to…".
81
+ * With `name` it names one file; with `all` it saves every file of a list.
82
+ */
83
+ const label = (locale: string, options: { name?: string; all?: boolean } = {}): string => {
84
+ const t = fileChooserMessages.resolve([locale]).t;
85
+ const savers = list.known()?.filter((provider) => provider.save) ?? [];
86
+ const app = savers.length === 1 ? savers[0]!.name : undefined;
87
+ if (options.all) return app ? t.saveAllTo({ app }) : t.saveAllToAny;
88
+ if (options.name) return app ? t.saveNamedTo({ name: options.name, app }) : t.saveNamedToAny({ name: options.name });
89
+ return app ? t.saveTo({ app }) : t.saveToAny;
90
+ };
91
+
92
+ /** An icon button that saves `files()` when activated; its label and tooltip name the file or say "all". */
93
+ function SaveFilesButton(
94
+ props: Omit<IconButtonProps, "children" | "label" | "onClick"> & { files: () => readonly SaveFileSource[]; all?: boolean },
95
+ ): JSX.Element {
96
+ const locale = useLocale();
97
+ const [local, rest] = splitProps(props, ["files", "all"]);
98
+ const name = () => (local.all ? undefined : local.files()[0]?.name);
99
+ return (
100
+ <IconButton {...rest} label={label(locale(), { name: name(), all: local.all })} onClick={() => void saveFiles(local.files())}>
101
+ <i class={SAVE_FILES_ICON} aria-hidden="true" />
102
+ </IconButton>
103
+ );
104
+ }
105
+
106
+ return { saveFiles, label, SaveFilesButton };
107
+ };
@@ -2,6 +2,8 @@ import {
2
2
  type AdminMailApp,
3
3
  type AdminMailProfile,
4
4
  type AdminMailRecord,
5
+ MailLogAccessSchema,
6
+ MailLogAppIdSchema,
5
7
  type MailPage,
6
8
  MailProfileInputSchema,
7
9
  type MailRetention,
@@ -181,8 +183,16 @@ export const outgoingMailCommands = [
181
183
  printJsonOrTable(
182
184
  ctx,
183
185
  result,
184
- result.items.map((app) => ({ ...app, profiles: app.profiles.join(",") })),
185
- [{ key: "appId" }, { key: "name" }, { key: "declared" }, { key: "mode" }, { key: "profiles" }],
186
+ result.items.map((app) => ({ ...app, reads: app.readDeclared, profiles: app.profiles.join(","), logApps: app.logApps.join(",") })),
187
+ [
188
+ { key: "appId" },
189
+ { key: "name" },
190
+ { key: "declared" },
191
+ { key: "reads" },
192
+ { key: "mode" },
193
+ { key: "profiles" },
194
+ { key: "logApps" },
195
+ ],
186
196
  );
187
197
  },
188
198
  }),
@@ -212,4 +222,22 @@ export const outgoingMailCommands = [
212
222
  );
213
223
  },
214
224
  }),
225
+ command("outgoing-mail apps set-log-access", {
226
+ summary: "Choose which other applications' mail logs an app may read",
227
+ args: { app: arg.required({ valueLabel: "app" }) },
228
+ flags: {
229
+ apps: flag.string({ description: "Comma-separated source application IDs" }),
230
+ ownOnly: flag.boolean({ description: "Remove all other application log grants" }),
231
+ yes: confirmFlag("Confirm application mail log access change"),
232
+ },
233
+ async run({ ctx, args, flags }) {
234
+ confirmed(flags.yes);
235
+ if ((flags.apps !== undefined) === !!flags.ownOnly) throw new Error("Choose exactly one of --apps or --own-only.");
236
+ const reader = MailLogAppIdSchema.safeParse(args.app);
237
+ const value = MailLogAccessSchema.safeParse({ apps: flags.apps?.split(",").map((app) => app.trim()) ?? [] });
238
+ if (!reader.success || args.app === "core" || !value.success || value.data.apps.includes(args.app))
239
+ throw new Error("Choose a valid reader and distinct source apps other than Core or the reader itself.");
240
+ print(ctx, await apiJson(ctx, "PUT", `${root}/apps/${encodeURIComponent(args.app)}/log-access`, value.data));
241
+ },
242
+ }),
215
243
  ];
@@ -119,6 +119,8 @@ export type AppMeta = {
119
119
  section: "primary" | "more" | "hidden";
120
120
  requiresAuth?: boolean;
121
121
  requiresRoles?: Role[];
122
+ /** Same-origin path of a `GET` route this app serves that returns an `AppNavBadge`; see `defineApp()`. */
123
+ badge?: string;
122
124
  };
123
125
  /**
124
126
  * Legal/info pages this app owns. Aggregated app-wide and rendered in
@@ -146,6 +148,14 @@ export type AppMeta = {
146
148
  cli?: AppCliModules;
147
149
  };
148
150
 
151
+ /**
152
+ * Response of an app's `nav.badge` route: how many items wait for the signed-in
153
+ * person. A positive safe integer shows a count on the app's icon in the rail
154
+ * and the app grid (above 99 as "99+"); `0`, a non-200 response, or anything
155
+ * else shows none.
156
+ */
157
+ export type AppNavBadge = { count: number };
158
+
149
159
  export type WidgetEndpoint = {
150
160
  /** Unique-within-the-app id, e.g. "open-requests". */
151
161
  id: string;
@@ -8,7 +8,9 @@ export const CommandPathSchema = z
8
8
  .refine((value) => {
9
9
  if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
10
10
  try {
11
- return new URL(value, "https://cloud.invalid").origin === "https://cloud.invalid";
11
+ const url = new URL(value, "https://cloud.invalid");
12
+ // Dot segments can resolve to `//host`, which a browser reads as another host.
13
+ return url.origin === "https://cloud.invalid" && !url.pathname.startsWith("//");
12
14
  } catch {
13
15
  return false;
14
16
  }
@@ -0,0 +1,6 @@
1
+ /** App-chosen keys stay separate from the namespaced tags sent to browsers. */
2
+ export const isNotificationGroup = (value: unknown): value is string =>
3
+ typeof value === "string" && value.length >= 1 && value.length <= 128 && /^[A-Za-z0-9._:-]+$/.test(value);
4
+
5
+ export const notificationGroupTag = (appId: string, group: string): string | null =>
6
+ typeof appId === "string" && /^[a-z][a-z0-9-]*$/.test(appId) && isNotificationGroup(group) ? `${appId}:${group}` : null;
@@ -31,6 +31,12 @@ export const validateNotificationTargetHref = (value: string): `/${string}` => {
31
31
  export type NotificationPresentation = {
32
32
  title: string;
33
33
  body?: string;
34
+ /** Short text shown in the browser/OS notification, including the lock screen. Cloud passes it only to the browser channel; email, deployment channels, and notification history never receive it. */
35
+ preview?: string;
36
+ /** Stable app-chosen browser group key, namespaced by Cloud per application. */
37
+ group?: string;
38
+ /** Non-negative safe integer for the app badge; zero clears it. Browser-only. */
39
+ badge?: number;
34
40
  /** Same-origin absolute path. Validated again before persistence and navigation. */
35
41
  targetHref?: `/${string}`;
36
42
  };