@k2b/cloud 0.35.0 → 0.37.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 +3 -3
- package/scripts/README.md +1 -0
- package/scripts/cli-pack.ts +123 -0
- package/src/_internal/shell-focus.ts +11 -0
- package/src/access/GroupCoverage.tsx +2 -1
- package/src/access/PermissionEditor.browser-harness.tsx +46 -0
- package/src/access/PermissionEditor.tsx +93 -35
- package/src/access/PrincipalPicker.tsx +2 -0
- package/src/access/messages.ts +2 -0
- package/src/access/ui.ts +1 -0
- package/src/ai/chat/answer-links.ts +8 -0
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/primitives.tsx +2 -1
- package/src/ai/executor.ts +46 -3
- package/src/ai/types.ts +1 -0
- package/src/browser/reload.ts +32 -1
- package/src/browser/shell-focus.ts +67 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/plugin.ts +5 -5
- package/src/services/index.ts +9 -0
- package/src/services/network-security.ts +48 -4
- package/src/shared/network-address.ts +80 -0
- package/src/ssr/CloudShell.island.tsx +2 -0
- package/src/ssr/GlobalSearchTrigger.tsx +1 -1
- package/src/ssr/Layout.tsx +8 -0
- package/src/ssr/LayoutHelpTrigger.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -1
- package/src/ssr/ProfilePreferences.tsx +1 -1
- package/src/ssr/RailApps.tsx +1 -1
- package/src/ssr/ShellFocusToggle.tsx +43 -0
- package/src/ssr/ShellRuntime.island.tsx +36 -1
- package/src/ssr/app-launchpad.tsx +1 -1
- package/src/ssr/platform-messages.ts +10 -0
- package/src/ssr/shell-focus.browser-harness.tsx +83 -0
- package/src/styles/global.css +155 -26
- package/src/styles/utilities-navigation.css +11 -3
package/src/ai/executor.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
|
|
|
23
23
|
import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
24
24
|
import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
|
|
25
25
|
import { aiChatMessages } from "./chat/messages";
|
|
26
|
+
import { isFailedTool } from "./chat/tool-groups";
|
|
26
27
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
27
28
|
import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
|
|
28
29
|
import { createCloudCompactFn } from "./compaction";
|
|
@@ -134,6 +135,17 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
|
|
|
134
135
|
};
|
|
135
136
|
};
|
|
136
137
|
|
|
138
|
+
const lastToolRoundFailed = (messages: AiStoredMessage[], blocks: AiTurnBlock[]): boolean => {
|
|
139
|
+
const latest = messages.findLast(({ message }) => message.role === "assistant")?.message;
|
|
140
|
+
if (latest?.role !== "assistant") return false;
|
|
141
|
+
const callIds = new Set(latest.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
|
|
142
|
+
// A worker may have stored a malformed call but crashed before its error result; load repairs that result without an event.
|
|
143
|
+
return (
|
|
144
|
+
[...callIds].some(isMalformedToolCallId) ||
|
|
145
|
+
blocks.some((block) => block.kind === "tool" && callIds.has(block.callId) && isFailedTool(block))
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
137
149
|
const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
|
|
138
150
|
try {
|
|
139
151
|
await aiConversations.indexConversationResources(input);
|
|
@@ -1500,6 +1512,7 @@ export class AiTurnExecutor {
|
|
|
1500
1512
|
const outcome = await this.driveChatLoop({
|
|
1501
1513
|
loop,
|
|
1502
1514
|
pipeline,
|
|
1515
|
+
initialRoundFailed: lastToolRoundFailed(turnMessages, turnBlocks),
|
|
1503
1516
|
conversationId,
|
|
1504
1517
|
turnId,
|
|
1505
1518
|
abortController,
|
|
@@ -1572,6 +1585,7 @@ export class AiTurnExecutor {
|
|
|
1572
1585
|
private async driveChatLoop(input: {
|
|
1573
1586
|
loop: NessiLoop;
|
|
1574
1587
|
pipeline: StreamPipeline;
|
|
1588
|
+
initialRoundFailed: boolean;
|
|
1575
1589
|
conversationId: string;
|
|
1576
1590
|
turnId: string;
|
|
1577
1591
|
abortController: AbortController;
|
|
@@ -1613,6 +1627,10 @@ export class AiTurnExecutor {
|
|
|
1613
1627
|
} = input;
|
|
1614
1628
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1615
1629
|
let lastIssueMessage: string | null = null;
|
|
1630
|
+
let lastResponse: AssistantMessage | undefined;
|
|
1631
|
+
let roundFailed = input.initialRoundFailed;
|
|
1632
|
+
let followedFailedRound = false;
|
|
1633
|
+
const malformedCallIds = new Set<string>();
|
|
1616
1634
|
|
|
1617
1635
|
try {
|
|
1618
1636
|
for await (const event of loop) {
|
|
@@ -1672,6 +1690,7 @@ export class AiTurnExecutor {
|
|
|
1672
1690
|
.noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
|
|
1673
1691
|
.catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
|
|
1674
1692
|
const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
|
|
1693
|
+
if (toolBlock?.kind === "tool" && isFailedTool(toolBlock)) roundFailed = true;
|
|
1675
1694
|
// The policy keys calls by the name the model called, as the persisted calls it seeds from.
|
|
1676
1695
|
noteToolCall(
|
|
1677
1696
|
toolBlock?.kind === "tool"
|
|
@@ -1687,20 +1706,44 @@ export class AiTurnExecutor {
|
|
|
1687
1706
|
result: event.result,
|
|
1688
1707
|
isError: event.isError === true,
|
|
1689
1708
|
});
|
|
1690
|
-
} else if (event.type === "turn_end"
|
|
1691
|
-
|
|
1709
|
+
} else if (event.type === "turn_end") {
|
|
1710
|
+
lastResponse = event.message;
|
|
1711
|
+
followedFailedRound = roundFailed;
|
|
1712
|
+
const calls = new Set(event.message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
|
|
1713
|
+
// nessi closes a round after executing its tools. Malformed calls have stored failed blocks,
|
|
1714
|
+
// but are added by Cloud's store wrapper, so they are absent from the event's message.
|
|
1715
|
+
roundFailed = pipeline.blocks.some(
|
|
1716
|
+
(block) => block.kind === "tool" && (calls.has(block.callId) || malformedCallIds.has(block.callId)) && isFailedTool(block),
|
|
1717
|
+
);
|
|
1718
|
+
malformedCallIds.clear();
|
|
1719
|
+
if (!abortController.signal.aborted && isToolRound(event.message)) noteToolRound();
|
|
1692
1720
|
} else if (event.type === "issue") {
|
|
1721
|
+
if (event.issue.kind === "malformed_tool_call" && event.issue.callId) malformedCallIds.add(event.issue.callId);
|
|
1693
1722
|
lastIssueMessage = event.issue.message;
|
|
1694
1723
|
log.warn("AI turn issue", { conversationId, turnId, kind: event.issue.kind, message: event.issue.message });
|
|
1695
1724
|
} else if (event.type === "loop_end") {
|
|
1725
|
+
const stoppedAfterFailure =
|
|
1726
|
+
event.reason === "stop" &&
|
|
1727
|
+
!abortController.signal.aborted &&
|
|
1728
|
+
followedFailedRound &&
|
|
1729
|
+
lastResponse !== undefined &&
|
|
1730
|
+
!lastResponse.content.some((block) => block.type === "tool_call" || (block.type === "text" && block.text.trim() !== ""));
|
|
1731
|
+
const doneReason = stoppedAfterFailure ? "error" : event.reason;
|
|
1696
1732
|
const aggregate = await withDurableTurnTiming(turnId, event.aggregate);
|
|
1697
1733
|
if (aggregate.assistantMessageCount > 0) {
|
|
1698
1734
|
await aiConversations
|
|
1699
|
-
.setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason
|
|
1735
|
+
.setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason })
|
|
1700
1736
|
.catch(() => undefined);
|
|
1701
1737
|
}
|
|
1702
1738
|
const timing = aggregate.timing;
|
|
1703
1739
|
if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
|
|
1740
|
+
if (stoppedAfterFailure)
|
|
1741
|
+
return {
|
|
1742
|
+
kind: "finished",
|
|
1743
|
+
status: "failed",
|
|
1744
|
+
failure: { error: { code: "tool_failed" }, detail: "The model stopped without an answer after a failed tool call." },
|
|
1745
|
+
timing,
|
|
1746
|
+
};
|
|
1704
1747
|
if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
|
|
1705
1748
|
const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
|
|
1706
1749
|
const reason: AiTurnFailureReason =
|
package/src/ai/types.ts
CHANGED
package/src/browser/reload.ts
CHANGED
|
@@ -9,11 +9,26 @@ import { type ToastHandle, toast } from "@k2b/ui";
|
|
|
9
9
|
*/
|
|
10
10
|
export const AUTOMATIC_RELOAD_WINDOW_MS = 30_000;
|
|
11
11
|
|
|
12
|
+
let reloadPending = false;
|
|
13
|
+
/** One notice at a time: every deferred reload has the same remedy. */
|
|
14
|
+
let pageChanged: ToastHandle | undefined;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An open modal dialog holds the reload. A modeless window, such as global search moved aside, leaves the page
|
|
18
|
+
* usable and may stay open for long, so it does not: the page would otherwise stay stale without notice.
|
|
19
|
+
*/
|
|
20
|
+
const modalOpen = () => document.querySelector('dialog[open]:not([aria-modal="false"])') !== null;
|
|
21
|
+
|
|
12
22
|
const storageKey = (key: string) => `cloud.reload.${key}`;
|
|
13
23
|
|
|
14
24
|
/**
|
|
15
25
|
* Reloads the page for an automatic reason (a live event, a failed live
|
|
16
26
|
* subscription, a changed view) at most once per `key` and window in this tab.
|
|
27
|
+
* While a modal dialog is open, it does not reload: once the last one closes,
|
|
28
|
+
* it shows a notice with a reload action. The person may have confirmed work
|
|
29
|
+
* in that dialog whose request a reload would cancel, so only they reload.
|
|
30
|
+
* The call returns `true` in both cases; a dialog that shows live data says
|
|
31
|
+
* itself that it is stale.
|
|
17
32
|
*
|
|
18
33
|
* Returns `false` without reloading when the same key already reloaded within
|
|
19
34
|
* the window, or when `sessionStorage` is unavailable and a repeat therefore
|
|
@@ -22,6 +37,7 @@ const storageKey = (key: string) => `cloud.reload.${key}`;
|
|
|
22
37
|
* need this guard.
|
|
23
38
|
*/
|
|
24
39
|
export const reloadOnce = (key: string, options: { windowMs?: number } = {}): boolean => {
|
|
40
|
+
if (reloadPending) return true;
|
|
25
41
|
const windowMs = options.windowMs ?? AUTOMATIC_RELOAD_WINDOW_MS;
|
|
26
42
|
const now = Date.now();
|
|
27
43
|
try {
|
|
@@ -32,7 +48,20 @@ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): bo
|
|
|
32
48
|
} catch {
|
|
33
49
|
return false;
|
|
34
50
|
}
|
|
35
|
-
|
|
51
|
+
if (typeof document === "undefined" || !modalOpen()) {
|
|
52
|
+
window.location.reload();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
reloadPending = true;
|
|
56
|
+
const observer = new MutationObserver(() => {
|
|
57
|
+
if (modalOpen()) return;
|
|
58
|
+
observer.disconnect();
|
|
59
|
+
reloadPending = false;
|
|
60
|
+
const t = reloadMessages.resolve([document.documentElement.lang || "en"]).t;
|
|
61
|
+
pageChanged?.dismiss();
|
|
62
|
+
pageChanged = toast(t.pageChanged, { duration: 0, action: { label: t.reload, onClick: () => window.location.reload() } });
|
|
63
|
+
});
|
|
64
|
+
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open", "aria-modal"] });
|
|
36
65
|
return true;
|
|
37
66
|
};
|
|
38
67
|
|
|
@@ -41,10 +70,12 @@ const reloadMessages = i18n.define({
|
|
|
41
70
|
messages: {
|
|
42
71
|
en: {
|
|
43
72
|
codeUnavailable: "Part of this page could not load. Reload the page and try again.",
|
|
73
|
+
pageChanged: "This page changed. Reload it to see the latest version.",
|
|
44
74
|
reload: "Reload",
|
|
45
75
|
},
|
|
46
76
|
de: {
|
|
47
77
|
codeUnavailable: "Ein Teil dieser Seite konnte nicht geladen werden. Lade die Seite neu und versuche es erneut.",
|
|
78
|
+
pageChanged: "Diese Seite hat sich geändert. Lade sie neu, um den aktuellen Stand zu sehen.",
|
|
48
79
|
reload: "Neu laden",
|
|
49
80
|
},
|
|
50
81
|
},
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { shellFocusCookie } from "../_internal/shell-focus";
|
|
2
|
+
|
|
3
|
+
export const SHELL_FOCUS_CHANGE_EVENT = "cloud:shell-focus-change";
|
|
4
|
+
|
|
5
|
+
const canvas = () => document.querySelector<HTMLElement>(".cloud-app-canvas[data-layout-authenticated]");
|
|
6
|
+
|
|
7
|
+
export const readShellFocus = (): boolean => canvas()?.dataset.shellFocus === "true";
|
|
8
|
+
|
|
9
|
+
export const setShellFocus = (hidden: boolean): void => {
|
|
10
|
+
const element = canvas();
|
|
11
|
+
if (!element) return;
|
|
12
|
+
const focused = document.activeElement;
|
|
13
|
+
document.cookie = shellFocusCookie(hidden, location.protocol === "https:");
|
|
14
|
+
if (hidden) element.dataset.shellFocus = "true";
|
|
15
|
+
else delete element.dataset.shellFocus;
|
|
16
|
+
window.dispatchEvent(new CustomEvent(SHELL_FOCUS_CHANGE_EVENT, { detail: { hidden } }));
|
|
17
|
+
if (
|
|
18
|
+
window.matchMedia("(min-width: 768px)").matches &&
|
|
19
|
+
(!focused ||
|
|
20
|
+
focused === document.body ||
|
|
21
|
+
focused.matches("[data-cloud-shell-focus-toggle]") ||
|
|
22
|
+
!focused.isConnected ||
|
|
23
|
+
focused.getClientRects().length === 0)
|
|
24
|
+
) {
|
|
25
|
+
element.querySelector<HTMLElement>(`[data-cloud-shell-focus-toggle="${hidden ? "show" : "hide"}"]`)?.focus({ preventScroll: true });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const toggleShellFocus = (): void => setShellFocus(!readShellFocus());
|
|
30
|
+
|
|
31
|
+
/** Delegation also handles the server's buttons before their islands hydrate. */
|
|
32
|
+
export const attachShellFocusControls = (): (() => void) => {
|
|
33
|
+
const click = (event: MouseEvent) => {
|
|
34
|
+
if (!(event.target instanceof Element)) return;
|
|
35
|
+
const button = event.target.closest<HTMLElement>("[data-cloud-shell-focus-toggle]");
|
|
36
|
+
if (button) setShellFocus(button.dataset.cloudShellFocusToggle === "hide");
|
|
37
|
+
};
|
|
38
|
+
let drag: { pointerId: number; x: number; y: number } | undefined;
|
|
39
|
+
const start = (event: PointerEvent) => {
|
|
40
|
+
if (event.pointerType === "mouse" || !(event.target instanceof Element) || !event.target.closest("[data-cloud-shell-reveal]")) return;
|
|
41
|
+
drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
|
42
|
+
};
|
|
43
|
+
const move = (event: PointerEvent) => {
|
|
44
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
45
|
+
const dx = event.clientX - drag.x;
|
|
46
|
+
if (dx >= 40 && dx > Math.abs(event.clientY - drag.y)) {
|
|
47
|
+
drag = undefined;
|
|
48
|
+
setShellFocus(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const stop = (event: PointerEvent) => {
|
|
52
|
+
if (event.pointerId === drag?.pointerId) drag = undefined;
|
|
53
|
+
};
|
|
54
|
+
document.addEventListener("click", click);
|
|
55
|
+
document.addEventListener("pointerdown", start);
|
|
56
|
+
window.addEventListener("pointermove", move);
|
|
57
|
+
window.addEventListener("pointerup", stop);
|
|
58
|
+
window.addEventListener("pointercancel", stop);
|
|
59
|
+
return () => {
|
|
60
|
+
document.removeEventListener("click", click);
|
|
61
|
+
document.removeEventListener("pointerdown", start);
|
|
62
|
+
window.removeEventListener("pointermove", move);
|
|
63
|
+
window.removeEventListener("pointerup", stop);
|
|
64
|
+
window.removeEventListener("pointercancel", stop);
|
|
65
|
+
drag = undefined;
|
|
66
|
+
};
|
|
67
|
+
};
|
package/src/cli/commands.ts
CHANGED
|
@@ -244,6 +244,8 @@ export const readCliInput = async (input: CliInputFlagValue, options: ReadCliInp
|
|
|
244
244
|
|
|
245
245
|
export const defineCliCommands = (config: CliCommandsConfig): CloudCliModule => {
|
|
246
246
|
const root = buildCommandTree(config.commands, config.groupSummaries);
|
|
247
|
+
// `cld <module> reference` prints the module's skill references instead.
|
|
248
|
+
if (root.children.has("reference")) throw new Error(`CLI module "${config.name}" must not declare the reserved command "reference"`);
|
|
247
249
|
if (config.alias !== undefined && !isCloudCliAlias(config.alias, config.name)) {
|
|
248
250
|
throw new Error("CLI alias must be a lowercase kebab-case name different from the module name");
|
|
249
251
|
}
|
package/src/cli/plugin.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `cld`
|
|
3
|
-
* `cld`, which installs and loads them.
|
|
2
|
+
* Verified `cld` bundle contract shared by application-served plugins,
|
|
3
|
+
* package plugins, and `cld`, which installs and loads them.
|
|
4
4
|
*
|
|
5
5
|
* An application serves each declared CLI module under
|
|
6
6
|
* `/cli/plugins/<name>/`: `manifest.json`, the bundled module `cli.js`, and
|
|
@@ -34,9 +34,9 @@ export type CloudCliPluginManifest = {
|
|
|
34
34
|
apiVersion: number;
|
|
35
35
|
/** Module name and plugin ID. */
|
|
36
36
|
name: string;
|
|
37
|
-
/** ID of the application
|
|
37
|
+
/** ID of the serving application, or name of the publishing package. */
|
|
38
38
|
app: string;
|
|
39
|
-
/**
|
|
39
|
+
/** Application build or package version. */
|
|
40
40
|
version: string;
|
|
41
41
|
entry: typeof CLOUD_CLI_PLUGIN_ENTRY;
|
|
42
42
|
/** SHA-512 over the file list; see {@link cloudCliPluginDigest}. */
|
|
@@ -88,7 +88,7 @@ export const isCloudCliShortcuts = (value: unknown, name: string, alias?: string
|
|
|
88
88
|
.every((segment) => !segment.startsWith("-")),
|
|
89
89
|
);
|
|
90
90
|
|
|
91
|
-
/** Validate a
|
|
91
|
+
/** Validate a bundle manifest, including its digest. Throws an `Error` naming the first problem. */
|
|
92
92
|
export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
|
|
93
93
|
if (!isRecord(value)) throw new Error("plugin manifest is not an object");
|
|
94
94
|
const { apiVersion, name, app, version, entry, digest, files } = value;
|
package/src/services/index.ts
CHANGED
|
@@ -218,3 +218,12 @@ export type {
|
|
|
218
218
|
MailPage,
|
|
219
219
|
MailServiceError,
|
|
220
220
|
} from "../contracts/outgoing-mail";
|
|
221
|
+
|
|
222
|
+
export {
|
|
223
|
+
NetworkTargetBlockedError,
|
|
224
|
+
isNetworkTargetBlocked,
|
|
225
|
+
pinnedNetworkLookup,
|
|
226
|
+
resolvePublicNetworkAddresses,
|
|
227
|
+
resolveAllowedNetworkAddresses,
|
|
228
|
+
} from "./network-security";
|
|
229
|
+
export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
2
|
import type { LookupFunction } from "node:net";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
EMPTY_NETWORK_ALLOWLIST,
|
|
5
|
+
isAllowlistedNetworkAddress,
|
|
6
|
+
isUnsafeNetworkAddress,
|
|
7
|
+
isUnsafeNetworkHostname,
|
|
8
|
+
type NetworkAllowlist,
|
|
9
|
+
normalizeNetworkHostname,
|
|
10
|
+
parseNetworkAddress,
|
|
11
|
+
} from "../shared/network-address";
|
|
4
12
|
|
|
5
13
|
export type PublicNetworkAddress = { address: string; family: 4 | 6 };
|
|
6
14
|
export type NetworkLookup = (
|
|
@@ -15,12 +23,14 @@ export class NetworkTargetBlockedError extends Error {
|
|
|
15
23
|
}
|
|
16
24
|
}
|
|
17
25
|
|
|
18
|
-
export const
|
|
26
|
+
export const resolveAllowedNetworkAddresses = async (
|
|
19
27
|
rawHostname: string,
|
|
28
|
+
allowlist: NetworkAllowlist,
|
|
20
29
|
lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
|
|
21
30
|
): Promise<PublicNetworkAddress[]> => {
|
|
22
31
|
const hostname = normalizeNetworkHostname(rawHostname);
|
|
23
|
-
|
|
32
|
+
const trustedHost = allowlist.hosts.has(hostname);
|
|
33
|
+
if (!trustedHost && isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
|
|
24
34
|
|
|
25
35
|
const literal = parseNetworkAddress(rawHostname);
|
|
26
36
|
const addresses = literal ? [literal] : await lookup(hostname, { all: true, verbatim: true });
|
|
@@ -33,12 +43,46 @@ export const resolvePublicNetworkAddresses = async (
|
|
|
33
43
|
if (!literal || literal.family !== entry.family) throw new NetworkTargetBlockedError("Network target returned an invalid address");
|
|
34
44
|
return literal;
|
|
35
45
|
});
|
|
36
|
-
if (
|
|
46
|
+
if (
|
|
47
|
+
!trustedHost &&
|
|
48
|
+
normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address))
|
|
49
|
+
) {
|
|
37
50
|
throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
|
|
38
51
|
}
|
|
39
52
|
return normalized;
|
|
40
53
|
};
|
|
41
54
|
|
|
55
|
+
/** Save-time hint only: every request must still resolve, validate and pin its target. */
|
|
56
|
+
export const isNetworkTargetBlocked = async (
|
|
57
|
+
hostname: string,
|
|
58
|
+
allowlist: NetworkAllowlist,
|
|
59
|
+
{
|
|
60
|
+
resolve = resolveAllowedNetworkAddresses,
|
|
61
|
+
timeoutMs = 5000,
|
|
62
|
+
}: {
|
|
63
|
+
resolve?: typeof resolveAllowedNetworkAddresses;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
} = {},
|
|
66
|
+
): Promise<boolean> => {
|
|
67
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
try {
|
|
69
|
+
await Promise.race([
|
|
70
|
+
resolve(hostname, allowlist),
|
|
71
|
+
new Promise<void>((resolve) => {
|
|
72
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
return false;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return error instanceof NetworkTargetBlockedError;
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const resolvePublicNetworkAddresses = (rawHostname: string, lookup?: NetworkLookup): Promise<PublicNetworkAddress[]> =>
|
|
84
|
+
resolveAllowedNetworkAddresses(rawHostname, EMPTY_NETWORK_ALLOWLIST, lookup);
|
|
85
|
+
|
|
42
86
|
/** Socket lookup using only addresses already checked by the public resolver. */
|
|
43
87
|
export const pinnedNetworkLookup =
|
|
44
88
|
(addresses: readonly PublicNetworkAddress[]): LookupFunction =>
|
|
@@ -106,3 +106,83 @@ export const isUnsafeNetworkHostname = (rawHostname: string): boolean => {
|
|
|
106
106
|
((endsInNumber(hostname) || hostname.includes(":")) && !parseNetworkAddress(hostname))
|
|
107
107
|
);
|
|
108
108
|
};
|
|
109
|
+
|
|
110
|
+
/** Operator-owned exact DNS names and canonical IP networks. Never populate from person input. */
|
|
111
|
+
export type NetworkAllowlist = {
|
|
112
|
+
readonly hosts: ReadonlySet<string>;
|
|
113
|
+
readonly networks: ReadonlyArray<Readonly<{ address: string; family: 4 | 6; bits: number }>>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const EMPTY_NETWORK_ALLOWLIST: NetworkAllowlist = { hosts: new Set(), networks: [] };
|
|
117
|
+
|
|
118
|
+
export const parseNetworkAllowlist = (entries: readonly string[]): NetworkAllowlist => {
|
|
119
|
+
const hosts = new Set<string>();
|
|
120
|
+
const networks: Array<{ address: string; family: 4 | 6; bits: number }> = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const invalid = (reason = "expected an exact host name, IP address or CIDR") =>
|
|
123
|
+
new Error(`Invalid network allowlist entry "${entry}": ${reason}`);
|
|
124
|
+
const parts = entry.split("/");
|
|
125
|
+
const raw = parts[0] ?? "";
|
|
126
|
+
const literal = parseNetworkAddress(raw);
|
|
127
|
+
if (literal) {
|
|
128
|
+
// Allowlist entries use strict IP notation, even though URL destinations may use legacy IPv4.
|
|
129
|
+
if (literal.family === 4 && ipv4ToNumber(raw) === null) throw invalid();
|
|
130
|
+
if (literal.family === 6 && raw.includes(".") && ipv4ToNumber(raw.replace(/\]$/, "").split(":").at(-1) ?? "") === null)
|
|
131
|
+
throw invalid();
|
|
132
|
+
const width = literal.family === 4 ? 32 : 128;
|
|
133
|
+
if (parts.length > 2 || (parts.length === 2 && !/^(0|[1-9]\d*)$/.test(parts[1] ?? ""))) throw invalid();
|
|
134
|
+
const bits = parts.length === 2 ? Number(parts[1]) : width;
|
|
135
|
+
if (bits > width) throw invalid();
|
|
136
|
+
if (bits === 0) throw invalid("a /0 network allows every address; list the internal hosts or ranges instead");
|
|
137
|
+
const value = literal.family === 4 ? BigInt(ipv4ToNumber(literal.address)!) : ipv6ToBigInt(literal.address);
|
|
138
|
+
const shift = BigInt(width - bits);
|
|
139
|
+
const base = (value >> shift) << shift;
|
|
140
|
+
if (base !== value) {
|
|
141
|
+
const address =
|
|
142
|
+
literal.family === 4
|
|
143
|
+
? [24n, 16n, 8n, 0n].map((shift) => Number((base >> shift) & 255n)).join(".")
|
|
144
|
+
: parseNetworkAddress(
|
|
145
|
+
Array.from({ length: 8 }, (_, index) => ((base >> BigInt((7 - index) * 16)) & 65535n).toString(16)).join(":"),
|
|
146
|
+
)!.address;
|
|
147
|
+
throw invalid(`host bits are set; use ${address}/${bits}`);
|
|
148
|
+
}
|
|
149
|
+
networks.push({ ...literal, bits });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const hostname = normalizeNetworkHostname(raw);
|
|
153
|
+
if (
|
|
154
|
+
parts.length !== 1 ||
|
|
155
|
+
endsInNumber(hostname) ||
|
|
156
|
+
raw.includes(":") ||
|
|
157
|
+
hostname.length > 253 ||
|
|
158
|
+
!hostname.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
|
159
|
+
)
|
|
160
|
+
throw invalid();
|
|
161
|
+
hosts.add(hostname);
|
|
162
|
+
}
|
|
163
|
+
return { hosts, networks };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Parse operator configuration with the variable name in startup errors. */
|
|
167
|
+
export const parseNetworkAllowlistEnv = (variable: string, entries: readonly string[]): NetworkAllowlist => {
|
|
168
|
+
try {
|
|
169
|
+
return parseNetworkAllowlist(entries);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(`${variable}: ${error instanceof Error ? error.message : "Invalid allowlist"}`);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const isAllowlistedNetworkAddress = (allowlist: NetworkAllowlist, rawAddress: string): boolean => {
|
|
176
|
+
const literal = parseNetworkAddress(rawAddress);
|
|
177
|
+
if (!literal) return false;
|
|
178
|
+
if (literal.family === 6) {
|
|
179
|
+
const value = ipv6ToBigInt(literal.address);
|
|
180
|
+
// Only IPv4-mapped IPv6 reaches the embedded IPv4 host directly.
|
|
181
|
+
if (value >> 32n === 0xffffn) {
|
|
182
|
+
const ipv4 = [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 255n)).join(".");
|
|
183
|
+
if (isAllowlistedNetworkAddress(allowlist, ipv4)) return true;
|
|
184
|
+
}
|
|
185
|
+
return allowlist.networks.some((network) => network.family === 6 && ipv6InRange(value, ipv6ToBigInt(network.address), network.bits));
|
|
186
|
+
}
|
|
187
|
+
return allowlist.networks.some((network) => network.family === 4 && ipv4InRange(literal.address, network.address, network.bits));
|
|
188
|
+
};
|
|
@@ -9,6 +9,7 @@ import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
|
9
9
|
import ProfilePreferences from "./ProfilePreferences";
|
|
10
10
|
import { platformMessages } from "./platform-messages";
|
|
11
11
|
import RailApps from "./RailApps";
|
|
12
|
+
import ShellFocusToggle from "./ShellFocusToggle";
|
|
12
13
|
import { useShellData } from "./shell-data";
|
|
13
14
|
|
|
14
15
|
type CloudShellProps =
|
|
@@ -32,6 +33,7 @@ function RailNavigation(props: { currentPath: string }) {
|
|
|
32
33
|
createEffect(() => setAppLaunchpadContext(data.apps, data.legalLinks, profile));
|
|
33
34
|
return (
|
|
34
35
|
<nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={t().apps}>
|
|
36
|
+
<ShellFocusToggle variant="rail" />
|
|
35
37
|
<RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
|
|
36
38
|
<AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
|
|
37
39
|
<div class="mt-auto shrink-0 flex flex-col items-center gap-1">
|
|
@@ -33,7 +33,7 @@ export default function GlobalSearchTrigger(props: GlobalSearchTriggerProps) {
|
|
|
33
33
|
delay={0}
|
|
34
34
|
content={t().searchShortcut}
|
|
35
35
|
>
|
|
36
|
-
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-search"} text-
|
|
36
|
+
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-search"} text-xl`} />
|
|
37
37
|
</Tooltip.Trigger>
|
|
38
38
|
);
|
|
39
39
|
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type { DateContext } from "@k2b/stdlib";
|
|
|
2
2
|
import { AppWorkspace, appWorkspaceLayoutStyle, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
|
|
3
3
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
4
4
|
import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
|
|
5
|
+
import { readShellFocusCookie } from "../_internal/shell-focus";
|
|
5
6
|
import { resolveNavMatch } from "../contracts/app"; // ==========================
|
|
6
7
|
import { isPwaShellAvailable } from "../contracts/pwa";
|
|
7
8
|
import { defaultRailPreferences } from "../contracts/rail-preferences";
|
|
@@ -25,6 +26,7 @@ import { platformMessages } from "./platform-messages";
|
|
|
25
26
|
import { sortRailApps } from "./rail-navigation";
|
|
26
27
|
import { requestPath } from "./request-path";
|
|
27
28
|
import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
|
|
29
|
+
import ShellFocusToggle from "./ShellFocusToggle";
|
|
28
30
|
import ShellRuntime from "./ShellRuntime.island";
|
|
29
31
|
import { type ShellData, ShellDataProvider } from "./shell-data";
|
|
30
32
|
|
|
@@ -187,6 +189,8 @@ export default function Layout(props: LayoutProps) {
|
|
|
187
189
|
? { title: pageTitle, ancestors: [] }
|
|
188
190
|
: derivePageTrail(title ?? appName, currentRoute?.prefix, appName, `${pathname}${new URL(c.req.raw.url).search}`);
|
|
189
191
|
const showRail = !!user;
|
|
192
|
+
// The app bar and the app's navigation hidden with Cmd/Ctrl+\ or the sidebar buttons; phones ignore it in CSS.
|
|
193
|
+
const shellFocus = showRail && readShellFocusCookie(cookie);
|
|
190
194
|
// The profile menu offers the mobile app only while the installation runs it.
|
|
191
195
|
const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
|
|
192
196
|
const profileName = user?.displayName || user?.uid || "?";
|
|
@@ -248,11 +252,13 @@ export default function Layout(props: LayoutProps) {
|
|
|
248
252
|
data-page-trail-title-only={titleOnly ? "" : undefined}
|
|
249
253
|
data-layout-full-page={fullPage ? "true" : undefined}
|
|
250
254
|
data-layout-authenticated={showRail ? "true" : undefined}
|
|
255
|
+
data-shell-focus={shellFocus ? "true" : undefined}
|
|
251
256
|
data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
|
|
252
257
|
>
|
|
253
258
|
<ShellRuntime
|
|
254
259
|
workspace={{ appId: currentApp?.id }}
|
|
255
260
|
pageTrail
|
|
261
|
+
focus={showRail}
|
|
256
262
|
help={runtimeHelp}
|
|
257
263
|
commands={{ searchResources: !!user }}
|
|
258
264
|
push={!!user}
|
|
@@ -265,6 +271,8 @@ export default function Layout(props: LayoutProps) {
|
|
|
265
271
|
/>
|
|
266
272
|
<ShellDataProvider data={shellData}>
|
|
267
273
|
{showRail && <LayoutRail currentPath={requestPath(c)} />}
|
|
274
|
+
{showRail && <ShellFocusToggle variant="float" />}
|
|
275
|
+
{showRail && <div class="cloud-shell-reveal" data-cloud-shell-reveal="" aria-hidden="true" />}
|
|
268
276
|
<div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
|
|
269
277
|
<LayoutHeader
|
|
270
278
|
authenticated={showRail}
|
|
@@ -31,7 +31,7 @@ export default function LayoutHelpTrigger(props: LayoutHelpTriggerProps) {
|
|
|
31
31
|
delay={0}
|
|
32
32
|
content={t().helpShortcut}
|
|
33
33
|
>
|
|
34
|
-
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-
|
|
34
|
+
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-xl`} />
|
|
35
35
|
</Tooltip.Trigger>
|
|
36
36
|
);
|
|
37
37
|
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -9,7 +9,7 @@ export default function LayoutRail(props: { currentPath: string }) {
|
|
|
9
9
|
const locale = useLocale();
|
|
10
10
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
11
11
|
return (
|
|
12
|
-
<aside class="layout-rail hidden w-
|
|
12
|
+
<aside class="layout-rail hidden w-14 shrink-0 flex-col md:flex">
|
|
13
13
|
<div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
|
|
14
14
|
<a href="/" aria-label={t().home}>
|
|
15
15
|
<img src="/branding/logo" alt="Logo" class="h-5 w-5" />
|
|
@@ -18,7 +18,7 @@ type ProfilePreferencesProps = {
|
|
|
18
18
|
export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
19
19
|
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
|
|
20
20
|
|
|
21
|
-
const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="
|
|
21
|
+
const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="sm" />;
|
|
22
22
|
|
|
23
23
|
return (
|
|
24
24
|
<div class="layout-profile-preferences">
|
package/src/ssr/RailApps.tsx
CHANGED
|
@@ -21,7 +21,7 @@ function RailIcon(props: { link: RailLink; currentPath: string }) {
|
|
|
21
21
|
aria-current={active() ? "page" : undefined}
|
|
22
22
|
style={appAccentStyle(props.link.accent)}
|
|
23
23
|
>
|
|
24
|
-
<i class={`${props.link.iconClass} text-
|
|
24
|
+
<i class={`${props.link.iconClass} text-xl`} aria-hidden="true" />
|
|
25
25
|
<span class="sr-only">{props.link.label}</span>
|
|
26
26
|
<AppBadge endpoint={props.link.badge} appId={props.link.appId} />
|
|
27
27
|
</a>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
2
|
+
import { platformMessages } from "./platform-messages";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two buttons of the shell's focus state. The app bar's button hides the
|
|
6
|
+
* app bar and the app's navigation; the floating one shows them again. Both
|
|
7
|
+
* are always rendered, and the canvas's `data-shell-focus` decides in CSS which
|
|
8
|
+
* one shows, so the server's first frame is final. `ShellRuntime` handles
|
|
9
|
+
* their clicks by `data-cloud-shell-focus-toggle`, so the floating button is
|
|
10
|
+
* plain server markup, with its shortcut in the native title, and needs no
|
|
11
|
+
* island of its own.
|
|
12
|
+
*/
|
|
13
|
+
export default function ShellFocusToggle(props: { variant: "rail" | "float" }) {
|
|
14
|
+
const locale = useLocale();
|
|
15
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
16
|
+
if (props.variant === "rail")
|
|
17
|
+
return (
|
|
18
|
+
<Tooltip.Trigger
|
|
19
|
+
type="button"
|
|
20
|
+
class="rail-item"
|
|
21
|
+
data-cloud-shell-focus-toggle="hide"
|
|
22
|
+
aria-label={t().hideSidebar}
|
|
23
|
+
placement="right"
|
|
24
|
+
delay={0}
|
|
25
|
+
content={t().hideSidebarShortcut}
|
|
26
|
+
>
|
|
27
|
+
<i class="ti ti-layout-sidebar-left-collapse text-xl" aria-hidden="true" />
|
|
28
|
+
</Tooltip.Trigger>
|
|
29
|
+
);
|
|
30
|
+
return (
|
|
31
|
+
<div class="cloud-shell-show">
|
|
32
|
+
<IconButton
|
|
33
|
+
variant="secondary"
|
|
34
|
+
size="lg"
|
|
35
|
+
data-cloud-shell-focus-toggle="show"
|
|
36
|
+
label={t().showSidebar}
|
|
37
|
+
title={t().showSidebarShortcut}
|
|
38
|
+
>
|
|
39
|
+
<i class="ti ti-layout-sidebar-left-expand text-xl" aria-hidden="true" />
|
|
40
|
+
</IconButton>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|