@k2b/cloud 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/package.json +6 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -1,14 +1,21 @@
1
1
  import { attachResizeSeparator, LocaleProvider, parkWorkspaceDetailDrawers } from "@k2b/ui";
2
- import { createComponent } from "solid-js";
2
+ import { createComponent, createSignal } from "solid-js";
3
3
  import { render } from "solid-js/web";
4
4
  import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, readPanelCookie, serializePanelCookie } from "../_internal/panel-cookie";
5
5
  import { CloudResourceRefSchema } from "../contracts/capabilities";
6
6
  import type { PanelPageContext } from "../contracts/panel-context";
7
- import { isPanelShortcut, PANEL_CONTEXT_PARAM, panelProviderNeedsConsent, withPanelContext } from "../contracts/panel-context-plain";
7
+ import {
8
+ isPanelPageContext,
9
+ isPanelShortcut,
10
+ PANEL_CONTEXT_PARAM,
11
+ panelProviderNeedsConsent,
12
+ withPanelContext,
13
+ } from "../contracts/panel-context-plain";
8
14
  import { helpShortcutGroups } from "../ssr/layout-help-start";
9
15
  import { platformMessages } from "../ssr/platform-messages";
10
16
  import { readShellData } from "../ssr/shell-data";
11
17
  import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "./command-shortcuts";
18
+ import PanelLeaveNotice from "./PanelLeaveNotice";
12
19
  import type { PanelRequest } from "./panel";
13
20
  import {
14
21
  PAGE_CONTEXT_CHANGED,
@@ -17,8 +24,9 @@ import {
17
24
  type PanelConsentRequest,
18
25
  type PanelState,
19
26
  } from "./panel-context-bridge";
27
+ import { panelCanLeave } from "./panel-leave";
20
28
  import PanelMenu from "./panel-menu";
21
- import { isPanelCloseMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
29
+ import { isPanelCloseMessage, isPanelNavigateMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
22
30
  import { importOnDemand } from "./reload";
23
31
 
24
32
  export const PANEL_READY_TIMEOUT_MS = 10_000;
@@ -53,13 +61,100 @@ export function installPanelSlot(): () => void {
53
61
  let provider = panel.providers.find((item) => item.id === slot.dataset.provider) ?? panel.providers[0]!;
54
62
  let frame = slot.querySelector<HTMLIFrameElement>("[data-cloud-panel-frame]");
55
63
  let disposed = false;
56
- let context = panel.context ?? null;
64
+ const [leaveBlocked, setLeaveBlocked] = createSignal(false);
65
+ const transitionTo = (action: () => void) => {
66
+ const target = frame?.contentWindow;
67
+ if (target && !panelCanLeave(target)) {
68
+ setLeaveBlocked(true);
69
+ return;
70
+ }
71
+ setLeaveBlocked(false);
72
+ action();
73
+ };
74
+ const leaveHost = document.createElement("div");
75
+ body.before(leaveHost);
76
+ const disposeLeave = render(
77
+ () =>
78
+ createComponent(LocaleProvider, {
79
+ locale: lang,
80
+ get children() {
81
+ return createComponent(PanelLeaveNotice, {
82
+ get blocked() {
83
+ return leaveBlocked();
84
+ },
85
+ locale: lang,
86
+ resume: () => {
87
+ setLeaveBlocked(false);
88
+ frame?.focus();
89
+ },
90
+ });
91
+ },
92
+ }),
93
+ leaveHost,
94
+ );
95
+ let base = panel.context ?? null;
96
+ let context = base;
97
+ let pageRoot = document.querySelector("[data-nav-root]");
98
+ let documentPageId = context?.pageId;
99
+ let lastSeq = context?.seq ?? 0;
100
+ // The retained provider may have locked onto this document before a swap and this lazy installer.
101
+ try {
102
+ const raw = frame && new URL(frame.src, location.href).searchParams.get(PANEL_CONTEXT_PARAM);
103
+ const initial: unknown = raw ? JSON.parse(raw) : null;
104
+ if (isPanelPageContext(initial)) {
105
+ documentPageId = initial.pageId;
106
+ lastSeq = context?.pageId === documentPageId ? Math.max(lastSeq, initial.seq) : initial.seq;
107
+ if (context && context.pageId !== documentPageId) context = { ...context, pageId: documentPageId, seq: ++lastSeq };
108
+ }
109
+ } catch {
110
+ // A malformed or absent URL context leaves the shown page's context in charge.
111
+ }
112
+ // Set when a swapped page's context has not been delivered yet; cleared by the next delivery.
113
+ let pageUnsent = false;
114
+ const followPage = () => {
115
+ const nextRoot = document.querySelector("[data-nav-root]");
116
+ if (nextRoot === pageRoot) return;
117
+ pageRoot = nextRoot;
118
+ pageUnsent = true;
119
+ const arriving = readShellData()?.panel;
120
+ // The arriving page carries the server's current consent answer, as a reload would; a newer revision wins.
121
+ const consent = arriving?.consent;
122
+ if (consent) {
123
+ // Page observations fence pending choices even when a read already learned this revision or the provider changed.
124
+ if (consent.revision > (observedRevisions.get(consent.provider) ?? 0)) observedRevisions.set(consent.provider, consent.revision);
125
+ if (consent.revision > (revisions.get(consent.provider) ?? 0)) {
126
+ answers.set(consent.provider, consent.allowed);
127
+ revisions.set(consent.provider, consent.revision);
128
+ }
129
+ }
130
+ if (consent && consent.provider === provider.id) {
131
+ if (consent.allowed !== answers.get(consent.provider) || consent.revision !== revisions.get(consent.provider)) {
132
+ // An older or unreadable answer (`allowed: null`) is what a reload would show: stop and read again.
133
+ void revalidate();
134
+ }
135
+ } else if (provider.consent && isOpen()) {
136
+ // The server sends an answer only while the shared cookie shows this provider open; without it, read again.
137
+ void revalidate();
138
+ } else if (consentPending === 0) {
139
+ // A reload would read these answers again when a consent provider opens; so does the next open here.
140
+ for (const item of panel.providers) if (item.consent) answers.delete(item.id);
141
+ answersRead = undefined;
142
+ }
143
+ base = arriving?.context ?? null;
144
+ if (base) {
145
+ documentPageId ??= base.pageId;
146
+ context = { ...base, pageId: documentPageId, seq: ++lastSeq };
147
+ } else context = null;
148
+ };
57
149
  let loadVersion = 0;
58
150
  const answers = new Map<string, boolean | null>();
59
151
  const revisions = new Map<string, number>();
152
+ // A newer server page supersedes choices made before it, including queued saves and conflict retries.
153
+ const observedRevisions = new Map<string, number>();
60
154
  if (panel.consent) {
61
155
  answers.set(panel.consent.provider, panel.consent.allowed);
62
156
  revisions.set(panel.consent.provider, panel.consent.revision);
157
+ observedRevisions.set(panel.consent.provider, panel.consent.revision);
63
158
  }
64
159
  const saving = new Set<string>();
65
160
  const errors = new Set<string>();
@@ -78,13 +173,13 @@ export function installPanelSlot(): () => void {
78
173
  // Whether the current frame has seen a context, in its URL or by message; only such a frame needs a clearing null.
79
174
  let disclosed = !!frame && new URL(frame.src, location.origin).searchParams.has(PANEL_CONTEXT_PARAM);
80
175
  // The frame may have navigated on its own; only the selected provider's page below its panel path may receive context.
81
- const showsProvider = () => {
176
+ const showsProvider = (requireReady = true) => {
82
177
  try {
83
178
  const shown = frame?.contentWindow?.location;
84
179
  const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
85
180
  return (
86
181
  !!shown &&
87
- frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id &&
182
+ (!requireReady || frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id) &&
88
183
  (shown.pathname === scope || shown.pathname.startsWith(`${scope}/`))
89
184
  );
90
185
  } catch {
@@ -106,6 +201,7 @@ export function installPanelSlot(): () => void {
106
201
  let lastShortcutsJson: string | undefined;
107
202
  const canSend = () => isOpen() && allowed() && !!context && slot.dataset.state === "ready" && !!frame?.contentWindow && showsProvider();
108
203
  const sendContext = () => {
204
+ pageUnsent = false;
109
205
  if (!canSend()) return;
110
206
  const list = !panelProviderNeedsConsent(provider.id) ? shortcuts() : undefined;
111
207
  frame?.contentWindow?.postMessage(
@@ -158,15 +254,16 @@ export function installPanelSlot(): () => void {
158
254
  if (forgetUrlContext()) disclosed = false;
159
255
  };
160
256
  const collectContext = () => {
257
+ followPage();
161
258
  if (!context) return;
162
259
  const collection: PageContextCollection = { sources: [] };
163
260
  window.dispatchEvent(new CustomEvent(PAGE_CONTEXT_COLLECT, { detail: collection }));
164
261
  const source = collection.sources.findLast((source) => source.scope !== undefined);
165
262
  const parsed = source ? CloudResourceRefSchema.safeParse(source.scope) : undefined;
166
- const scope = source ? (parsed?.success ? parsed.data : undefined) : panel.context?.scope;
263
+ const scope = source ? (parsed?.success ? parsed.data : undefined) : base?.scope;
167
264
  if (scope?.type === context.scope?.type && scope?.id === context.scope?.id) return;
168
- const { scope: _previous, ...base } = context;
169
- context = { ...base, seq: context.seq + 1, ...(scope ? { scope } : {}) };
265
+ const { scope: _previous, ...previous } = context;
266
+ context = { ...previous, seq: ++lastSeq, ...(scope ? { scope } : {}) };
170
267
  sendContext();
171
268
  };
172
269
  // Share or stop sharing after every consent step; an open question or its absence follows in update().
@@ -220,8 +317,13 @@ export function installPanelSlot(): () => void {
220
317
  }
221
318
  for (const item of panel.providers) {
222
319
  if (!item.consent) continue;
320
+ const knownRevision = revisions.get(item.id) ?? 0;
321
+ const readRevision = nextRevisions.get(item.id);
322
+ // A page swap can observe a newer answer while this request is in flight. Never rewind that answer.
323
+ if (readRevision !== undefined && readRevision < knownRevision) continue;
223
324
  answers.set(item.id, next.get(item.id) ?? null);
224
- revisions.set(item.id, nextRevisions.get(item.id) ?? 0);
325
+ // An unreadable answer still stops sharing, but must not forget the newest observed revision.
326
+ revisions.set(item.id, readRevision ?? knownRevision);
225
327
  }
226
328
  answersStale = false;
227
329
  };
@@ -330,7 +432,7 @@ export function installPanelSlot(): () => void {
330
432
  clearTimer();
331
433
  slot.dataset.state = "ready";
332
434
  if (status) status.textContent = "";
333
- if (allowed()) {
435
+ if (allowed() && context) {
334
436
  sendContext();
335
437
  return;
336
438
  }
@@ -427,6 +529,8 @@ export function installPanelSlot(): () => void {
427
529
  if (!item || typeof request.allowed !== "boolean" || saving.has(item.id)) return;
428
530
  saving.add(item.id);
429
531
  errors.delete(item.id);
532
+ const observedRevision = observedRevisions.get(item.id);
533
+ const current = () => observedRevisions.get(item.id) === observedRevision;
430
534
  const questionHadFocus = slot.querySelector("[data-cloud-panel-consent]")?.contains(document.activeElement);
431
535
  // Enqueueing blocks sharing at once, so a withdrawal stops disclosure before its save even starts.
432
536
  // keepalive: leaving the page does not cancel the save. A save the browser gives up on (timeout) is rolled back
@@ -441,11 +545,14 @@ export function installPanelSlot(): () => void {
441
545
  });
442
546
  void enqueueConsent(async () => {
443
547
  try {
548
+ if (!current()) return;
444
549
  let response = await put();
550
+ if (!current()) return;
445
551
  // Changed meanwhile (another tab, or a read that failed): the person's choice now is the newest one, so read
446
552
  // the current revision and save it once more. A second conflict shows the question again.
447
553
  if (response.status === 409) {
448
554
  await readAnswersNow();
555
+ if (!current()) return;
449
556
  response = await put();
450
557
  }
451
558
  if (!response.ok) throw new Error("Panel consent was not saved");
@@ -463,13 +570,17 @@ export function installPanelSlot(): () => void {
463
570
  saved.revision <= 0
464
571
  )
465
572
  throw new Error("Invalid panel consent answer");
466
- answers.set(item.id, request.allowed);
467
- revisions.set(item.id, saved.revision);
573
+ if (current() && saved.revision >= (revisions.get(item.id) ?? 0)) {
574
+ answers.set(item.id, request.allowed);
575
+ revisions.set(item.id, saved.revision);
576
+ }
468
577
  } catch {
578
+ // A superseded request cannot invalidate the newer page's answer either.
579
+ if (!current()) return;
469
580
  // Not saved: nothing is shared until the person answers again; the read learns the current revision.
470
581
  errors.add(item.id);
471
582
  await readAnswersNow();
472
- answers.set(item.id, null);
583
+ if (current()) answers.set(item.id, null);
473
584
  } finally {
474
585
  saving.delete(item.id);
475
586
  }
@@ -509,9 +620,10 @@ export function installPanelSlot(): () => void {
509
620
  }
510
621
  main.focus();
511
622
  };
512
- const close = () => {
623
+ const closeNow = () => {
513
624
  if (!isOpen()) return;
514
625
  canvas.removeAttribute(attribute());
626
+ if (!isPhone()) canvas.removeAttribute("data-panel-sheet");
515
627
  disposeResize();
516
628
  disposeResize = attachResize();
517
629
  unload();
@@ -520,7 +632,8 @@ export function installPanelSlot(): () => void {
520
632
  update();
521
633
  returnFocus();
522
634
  };
523
- const open = (id: string, nextSearch?: string) => {
635
+ const close = () => transitionTo(closeNow);
636
+ const openNow = (id: string, nextSearch?: string) => {
524
637
  const next = panel.providers.find((item) => item.id === id);
525
638
  if (!next) return;
526
639
  parkWorkspaceDetailDrawers(document);
@@ -542,6 +655,7 @@ export function installPanelSlot(): () => void {
542
655
  if (frame) focusPanel();
543
656
  else focusPending = true;
544
657
  };
658
+ const open = (id: string, nextSearch?: string) => transitionTo(() => openNow(id, nextSearch));
545
659
  const request = (request: PanelRequest) => {
546
660
  if (request.action === "open") open(request.provider, request.search);
547
661
  else if (request.action === "close" || isOpen()) close();
@@ -551,7 +665,17 @@ export function installPanelSlot(): () => void {
551
665
  if (!frame || event.source !== frame.contentWindow || event.origin !== location.origin) return;
552
666
  if (isPanelReadyMessage(event.data, provider.id)) markReady();
553
667
  else if (isPanelCloseMessage(event.data)) close();
554
- else if (isPanelSearchMessage(event.data)) {
668
+ else if (isPanelNavigateMessage(event.data)) {
669
+ if (!URL.canParse(event.data.href)) return;
670
+ const url = new URL(event.data.href, location.origin);
671
+ if (url.origin !== location.origin || !showsProvider(false)) return;
672
+ const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
673
+ const internal = url.pathname === scope || url.pathname.startsWith(`${scope}/`);
674
+ transitionTo(() => {
675
+ if (internal) frame?.contentWindow?.location.assign(url.href);
676
+ else window.location.assign(url.href);
677
+ });
678
+ } else if (isPanelSearchMessage(event.data)) {
555
679
  const command = contextCommandsWithShortcuts().find((command) => command.id === "cloud.search" && command.shortcut);
556
680
  if (command) void importOnDemand(() => import("./commands")).then((commands) => commands?.runContextAwareCommand(command));
557
681
  }
@@ -562,8 +686,10 @@ export function installPanelSlot(): () => void {
562
686
  if (answer && isOpen()) void saveConsent({ provider: provider.id, allowed: answer.dataset.cloudPanelConsentAnswer === "allow" });
563
687
  else if (event.target.closest("[data-cloud-panel-close]")) close();
564
688
  else if (event.target.closest("[data-cloud-panel-retry]") && isOpen()) {
565
- load(true);
566
- frame?.focus();
689
+ transitionTo(() => {
690
+ load(true);
691
+ frame?.focus();
692
+ });
567
693
  } else if (event.target.closest("[data-cloud-panel-grow]") && isPhone()) {
568
694
  const full = slot.dataset.panelSize !== "full";
569
695
  if (full) slot.dataset.panelSize = "full";
@@ -599,17 +725,18 @@ export function installPanelSlot(): () => void {
599
725
  if (isOpen()) {
600
726
  seedWidth();
601
727
  load();
728
+ } else if (frame) {
729
+ // A viewport change must not hide an editor while its leave decision is blocked.
730
+ canvas.setAttribute(attribute(), "open");
731
+ transitionTo(closeNow);
602
732
  } else unload();
603
733
  disposeResize = attachResize();
604
734
  update();
605
735
  };
606
736
  const closeSheet = () => {
607
737
  const wasOpen = canvas.dataset.panelSheet === "open";
608
- canvas.removeAttribute("data-panel-sheet");
609
- if (wasOpen && isPhone()) {
610
- unload();
611
- update();
612
- }
738
+ if (wasOpen && isPhone()) close();
739
+ else canvas.removeAttribute("data-panel-sheet");
613
740
  };
614
741
  const pageShow = (event: PageTransitionEvent) => {
615
742
  if (!event.persisted) return;
@@ -657,6 +784,12 @@ export function installPanelSlot(): () => void {
657
784
  committedWidth = value;
658
785
  writeCookie();
659
786
  },
787
+ onReset: () => {
788
+ committedWidth = undefined;
789
+ canvas.style.removeProperty("--cloud-panel-width-set");
790
+ writeCookie();
791
+ seedWidth();
792
+ },
660
793
  onCollapseIntent: (armed) => {
661
794
  if (armed) slot.dataset.closing = "true";
662
795
  else delete slot.dataset.closing;
@@ -680,7 +813,18 @@ export function installPanelSlot(): () => void {
680
813
  })
681
814
  : () => {};
682
815
  let disposeResize = attachResize();
816
+ // A column island may already have published its place while it mounted; each page's context goes out once.
817
+ const swapped = () => {
818
+ collectContext();
819
+ if (pageUnsent) {
820
+ pageUnsent = false;
821
+ if (context && allowed()) sendContext();
822
+ else stopSharing();
823
+ } else if (provider.consent && !allowed()) stopSharing();
824
+ update();
825
+ };
683
826
  collectContext();
827
+ window.addEventListener("ssr:nav-swapped", swapped);
684
828
  window.addEventListener(PAGE_CONTEXT_CHANGED, collectContext);
685
829
  window.addEventListener(COMMANDS_CHANGED, commandsChanged);
686
830
  window.addEventListener("cloud.panel.consent", consentRequest);
@@ -693,6 +837,7 @@ export function installPanelSlot(): () => void {
693
837
  // Restored from the back/forward cache before this lazy module arrived: the server's answers may be outdated.
694
838
  if (document.documentElement.hasAttribute("data-cloud-page-restored")) void revalidate();
695
839
  window.addEventListener("popstate", closeSheet);
840
+ window.addEventListener("ssr:nav-popstate", closeSheet);
696
841
  window.navigation?.addEventListener("currententrychange", entryChange);
697
842
  window.addEventListener("resize", seedWidth);
698
843
  // An SSR iframe can have posted its one-shot ready before this lazy module
@@ -730,6 +875,8 @@ export function installPanelSlot(): () => void {
730
875
  const dispose = () => {
731
876
  if (disposed) return;
732
877
  disposed = true;
878
+ disposeLeave();
879
+ leaveHost.remove();
733
880
  disposeMenu();
734
881
  requests.abort();
735
882
  ++loadVersion;
@@ -737,6 +884,7 @@ export function installPanelSlot(): () => void {
737
884
  clearTimer();
738
885
  disposeResize();
739
886
  removeFallbackFocus();
887
+ window.removeEventListener("ssr:nav-swapped", swapped);
740
888
  window.removeEventListener(PAGE_CONTEXT_CHANGED, collectContext);
741
889
  window.removeEventListener(COMMANDS_CHANGED, commandsChanged);
742
890
  window.removeEventListener("cloud.panel.consent", consentRequest);
@@ -747,6 +895,7 @@ export function installPanelSlot(): () => void {
747
895
  column.removeEventListener("change", modeChange);
748
896
  window.removeEventListener("pageshow", pageShow);
749
897
  window.removeEventListener("popstate", closeSheet);
898
+ window.removeEventListener("ssr:nav-popstate", closeSheet);
750
899
  window.navigation?.removeEventListener("currententrychange", entryChange);
751
900
  window.removeEventListener("resize", seedWidth);
752
901
  installed = undefined;
@@ -1,4 +1,7 @@
1
1
  import type { PanelPageContext } from "../contracts/panel-context";
2
+
3
+ export { onPanelBeforeLeave } from "./panel-leave";
4
+
2
5
  import {
3
6
  PAGE_CONTEXT_CHANGED,
4
7
  PAGE_CONTEXT_COLLECT,
@@ -4,6 +4,7 @@ export const resourceSearchMessages = i18n.define({
4
4
  baseLocale: "en",
5
5
  messages: {
6
6
  en: {
7
+ app: "App",
7
8
  actions: "Actions",
8
9
  contextActions: "On this page",
9
10
  commandPlaceholder: "Find an action…",
@@ -54,6 +55,7 @@ export const resourceSearchMessages = i18n.define({
54
55
  loadingTags: "Loading filters…",
55
56
  },
56
57
  de: {
58
+ app: "App",
57
59
  actions: "Aktionen",
58
60
  contextActions: "Auf dieser Seite",
59
61
  commandPlaceholder: "Aktion finden…",
@@ -2,8 +2,13 @@ import { z } from "zod";
2
2
  import {
3
3
  CAPABILITY_FRAMEWORK_ERROR_CODES,
4
4
  CAPABILITY_MAX_CATALOG_BYTES,
5
+ CAPABILITY_MAX_REVIEW_CONTENT_BYTES,
6
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
5
7
  CapabilityActionReviewDataSchema,
6
8
  CapabilityCatalogSchema,
9
+ type CapabilityReviewContentRef,
10
+ CapabilityReviewContentRefSchema,
11
+ CapabilityReviewContentSchema,
7
12
  capabilityResultSchema,
8
13
  } from "../contracts/capabilities";
9
14
  import { capabilityMessages } from "../shared/capability-messages";
@@ -142,7 +147,29 @@ export const reviewCapabilityAction = async <TInput = unknown>(
142
147
  body: JSON.stringify({ input: invocation.input }),
143
148
  signal: invocation.signal,
144
149
  });
145
- return readCapabilityResponse(response, CapabilityActionReviewDataSchema);
150
+ return await readCapabilityResponse(
151
+ response,
152
+ CapabilityActionReviewDataSchema,
153
+ CAPABILITY_MAX_REVIEW_RESPONSE_BYTES,
154
+ invocation.signal,
155
+ );
156
+ } catch (cause) {
157
+ return unavailable(cause, undefined, clientLocale(options.headers));
158
+ }
159
+ };
160
+
161
+ /** Load exactly one stored snapshot using the caller's normal Cloud authentication. A locator grants no access. */
162
+ export const readCapabilityReviewContent = async (
163
+ reference: CapabilityReviewContentRef,
164
+ options: CapabilityHttpOptions & { signal?: AbortSignal } = {},
165
+ ) => {
166
+ try {
167
+ const ref = CapabilityReviewContentRefSchema.parse(reference);
168
+ const response = await (options.fetch ?? globalThis.fetch)(
169
+ requestUrl(options.baseUrl ?? "/api", `${ref.href.slice(4)}?digest=${encodeURIComponent(ref.digest)}`),
170
+ { headers: options.headers, credentials: "same-origin", cache: "no-store", signal: options.signal },
171
+ );
172
+ return await readCapabilityResponse(response, CapabilityReviewContentSchema, CAPABILITY_MAX_REVIEW_CONTENT_BYTES, options.signal);
146
173
  } catch (cause) {
147
174
  return unavailable(cause, undefined, clientLocale(options.headers));
148
175
  }
@@ -1,5 +1,7 @@
1
1
  import { sql } from "bun";
2
+ import { createCapabilityDeadline } from "../_internal/capability-timeout";
2
3
  import type { CapabilityOrigin } from "../contracts/capabilities";
4
+ import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
3
5
  import { pruneApprovalRequests } from "../services/approvals";
4
6
  import { logger } from "../services/logging";
5
7
  import { CAPABILITY_CLAIM_RETENTION_HOURS, pruneCapabilityIdempotencyClaims } from "./claims";
@@ -101,7 +103,17 @@ export type CapabilityExecution = {
101
103
  /** Insert one execution record. Called only by the capability dispatcher and the surfaces that deny before it. */
102
104
  export const recordCapabilityExecution = async (input: CapabilityExecutionInput): Promise<string> => {
103
105
  const durationMs = Math.max(0, Math.round(input.completedAt.getTime() - input.startedAt.getTime()));
104
- const [row] = await sql<{ id: string }[]>`
106
+ // An observational write has its own transport-headroom budget, even when
107
+ // the invocation already expired. Do not hold its caller on a dead database.
108
+ const deadline = createCapabilityDeadline(REQUEST_IDLE_TIMEOUT_SECONDS * 1000, new AbortController().signal);
109
+ try {
110
+ return await deadline.wait(() =>
111
+ sql.begin(async (db) => {
112
+ // A connection obtained after the deadline must never start the insert.
113
+ deadline.check();
114
+ await db`SELECT set_config('statement_timeout', ${`${deadline.remainingMs()}ms`}, true)`;
115
+ deadline.check();
116
+ const [row] = await db<{ id: string }[]>`
105
117
  INSERT INTO capabilities.executions (
106
118
  request_id, origin, app_id, capability, kind, destructive,
107
119
  actor_kind, actor_id, user_id, access_subject_type, access_subject_id,
@@ -117,8 +129,15 @@ export const recordCapabilityExecution = async (input: CapabilityExecutionInput)
117
129
  ${input.idempotencyKey ?? null}, ${input.replayed ?? false}, ${input.startedAt}, ${input.completedAt}, ${durationMs}
118
130
  )
119
131
  RETURNING id::text AS id
120
- `;
121
- return row!.id;
132
+ `;
133
+ // Roll back a late result rather than commit an abandoned transaction.
134
+ deadline.check();
135
+ return row!.id;
136
+ }),
137
+ );
138
+ } finally {
139
+ deadline.clear();
140
+ }
122
141
  };
123
142
 
124
143
  export type CapabilityExecutionFilter = {
@@ -0,0 +1,57 @@
1
+ import type { z } from "zod";
2
+ import { geoContract } from "../contracts/geo";
3
+ import { geoMapContract } from "../contracts/geo-map";
4
+ import { geoRouteContract } from "../contracts/geo-route";
5
+ import { type CapabilityCaller, invokeCapabilityWithDataSchema } from "./server";
6
+
7
+ /** Calls Core using the caller's interactive identity, invocation transport, or durable mandate. */
8
+ export const geoClient = {
9
+ mapStyle: (input: z.input<typeof geoMapContract.style.input>, caller: CapabilityCaller) =>
10
+ invokeCapabilityWithDataSchema(
11
+ { kind: "query", appId: "core", capabilityId: geoMapContract.style.id, input },
12
+ geoMapContract.style.data,
13
+ caller,
14
+ ),
15
+ calculateRoute: (input: z.input<typeof geoRouteContract.calculateRoute.input>, caller: CapabilityCaller) =>
16
+ invokeCapabilityWithDataSchema(
17
+ { kind: "query", appId: "core", capabilityId: geoRouteContract.calculateRoute.id, input },
18
+ geoRouteContract.calculateRoute.data,
19
+ caller,
20
+ ),
21
+ updateRoute: (input: z.input<typeof geoRouteContract.updateRoute.input>, caller: CapabilityCaller) =>
22
+ invokeCapabilityWithDataSchema(
23
+ { kind: "query", appId: "core", capabilityId: geoRouteContract.updateRoute.id, input },
24
+ geoRouteContract.updateRoute.data,
25
+ caller,
26
+ ),
27
+ autocomplete: (input: z.input<typeof geoContract.autocomplete.input>, caller: CapabilityCaller) =>
28
+ invokeCapabilityWithDataSchema(
29
+ { kind: "query", appId: "core", capabilityId: geoContract.autocomplete.id, input },
30
+ geoContract.autocomplete.data,
31
+ caller,
32
+ ),
33
+ geocode: (input: z.input<typeof geoContract.geocode.input>, caller: CapabilityCaller) =>
34
+ invokeCapabilityWithDataSchema(
35
+ { kind: "query", appId: "core", capabilityId: geoContract.geocode.id, input },
36
+ geoContract.geocode.data,
37
+ caller,
38
+ ),
39
+ reverse: (input: z.input<typeof geoContract.reverse.input>, caller: CapabilityCaller) =>
40
+ invokeCapabilityWithDataSchema(
41
+ { kind: "query", appId: "core", capabilityId: geoContract.reverse.id, input },
42
+ geoContract.reverse.data,
43
+ caller,
44
+ ),
45
+ searchPlaces: (input: z.input<typeof geoContract.searchPlaces.input>, caller: CapabilityCaller) =>
46
+ invokeCapabilityWithDataSchema(
47
+ { kind: "query", appId: "core", capabilityId: geoContract.searchPlaces.id, input },
48
+ geoContract.searchPlaces.data,
49
+ caller,
50
+ ),
51
+ availability: (caller: CapabilityCaller) =>
52
+ invokeCapabilityWithDataSchema(
53
+ { kind: "query", appId: "core", capabilityId: geoContract.availability.id, input: {} },
54
+ geoContract.availability.data,
55
+ caller,
56
+ ),
57
+ };
@@ -7,8 +7,9 @@ export const readCapabilityResponse = async <T>(
7
7
  response: Response,
8
8
  schema: z.ZodType<T>,
9
9
  maxBytes = CAPABILITY_MAX_RESULT_BYTES,
10
+ signal?: AbortSignal,
10
11
  ): Promise<CapabilityResultState<T>> => {
11
- const parsedBody = await readBoundedJson(response, maxBytes);
12
+ const parsedBody = await readBoundedJson(response, maxBytes, signal);
12
13
  if (!parsedBody.ok && parsedBody.reason === "too_large") {
13
14
  return {
14
15
  ok: false,
@@ -0,0 +1,24 @@
1
+ import { CapabilityReviewContentSchema } from "../contracts/capabilities";
2
+
3
+ /** JSONB cannot store NUL or lone UTF-16 surrogates. Nest the content's JSON as
4
+ * text in the existing snapshot, preserving every code unit and the public digest. */
5
+ export const serializeReviewSnapshot = (review: unknown): string => {
6
+ if (review && typeof review === "object" && "content" in review && review.content !== undefined)
7
+ return JSON.stringify({ ...review, content: JSON.stringify(CapabilityReviewContentSchema.parse(review.content)) });
8
+ return JSON.stringify(review);
9
+ };
10
+
11
+ export const parseStoredReviewContent = (value: unknown) => {
12
+ try {
13
+ return CapabilityReviewContentSchema.safeParse(typeof value === "string" ? JSON.parse(value) : value);
14
+ } catch {
15
+ return CapabilityReviewContentSchema.safeParse(null);
16
+ }
17
+ };
18
+
19
+ export const restoreReviewSnapshot = (review: unknown): unknown => {
20
+ if (!review || typeof review !== "object" || !("content" in review)) return review;
21
+ const content = parseStoredReviewContent(review.content);
22
+ // Invalid stored content must fail validation, never become a content-free review.
23
+ return { ...review, content: content.success ? content.data : null };
24
+ };
@@ -14,10 +14,11 @@ export const capabilityPreviewIssues = (
14
14
  preview: CapabilityPreview | undefined,
15
15
  links: readonly CapabilitySemanticLink[] | undefined,
16
16
  sends = false,
17
+ completeContent = false,
17
18
  ): string[] => {
18
19
  if (!preview) return [];
19
20
  const issues: string[] = [];
20
- const open = links?.some((link) => link.rel === "open");
21
+ const open = completeContent || links?.some((link) => link.rel === "open");
21
22
  if ("truncated" in preview && preview.truncated && !open) issues.push("A truncated preview requires an open link");
22
23
  if (preview.kind === "message") {
23
24
  if ((Object.values(preview.more ?? {}).some((count) => count > 0) || (preview.moreAttachments ?? 0) > 0) && !open)
@@ -33,9 +34,22 @@ export const capabilityReviewFormIssues = (review: CapabilityActionCallReview):
33
34
  for (const form of ["confirm", "decline"] as const)
34
35
  if (Array.from(capabilityReviewSentence(review, form, { timeZone: "UTC" })).length > 40)
35
36
  issues.push(`${form} exceeds 40 rendered characters`);
36
- issues.push(...capabilityPreviewIssues(review.preview, review.links, review.ask === "sends"));
37
+ issues.push(...capabilityPreviewIssues(review.preview, review.links, review.ask === "sends", Boolean(review.content)));
38
+ if (
39
+ review.content &&
40
+ review.preview &&
41
+ review.content.kind !== review.preview.kind &&
42
+ !(review.content.kind === "message" && review.preview.kind === "page")
43
+ )
44
+ issues.push("Full content must have the same kind as its preview");
45
+ if (review.content?.kind === "message" && review.ask === "sends") {
46
+ if (!review.content.from) issues.push("Full sends content requires from");
47
+ if (!review.content.to.length && !review.content.cc?.length && !review.content.bcc?.length)
48
+ issues.push("Full sends content requires a recipient");
49
+ }
37
50
  try {
38
- if (new TextEncoder().encode(JSON.stringify(review)).byteLength > CAPABILITY_MAX_REVIEW_BYTES) issues.push("Review exceeds 32 KiB");
51
+ const { content: _content, ...compact } = review;
52
+ if (new TextEncoder().encode(JSON.stringify(compact)).byteLength > CAPABILITY_MAX_REVIEW_BYTES) issues.push("Review exceeds 32 KiB");
39
53
  } catch {
40
54
  issues.push("Review is not JSON serializable");
41
55
  }